Compare commits

..
Author SHA1 Message Date
pewdiepie-archdaemon 6cf676dfe2 feat(email): export selected messages as email or PDF 2026-10-15 12:02:06 +00:00
33 changed files with 926 additions and 784 deletions
+188 -2
View File
@@ -26,12 +26,14 @@ import re
import html
import io
import zipfile
from urllib.parse import parse_qs, unquote, urlparse
from urllib.parse import parse_qs, quote, unquote, urlparse
from html.parser import HTMLParser as _HTMLParser
import logging
import uuid
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from email.message import EmailMessage
from email import policy as email_policy
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
@@ -4185,6 +4187,190 @@ def setup_email_routes():
headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
)
@router.post("/messages-download-bulk")
async def download_bulk_messages(request: Request, owner: str = Depends(require_owner)):
"""Export selected original email messages as .eml files in a ZIP."""
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
messages = payload.get("messages") if isinstance(payload, dict) else None
if not isinstance(messages, list) or not messages:
raise HTTPException(status_code=400, detail="No emails selected")
if len(messages) > 250:
raise HTTPException(status_code=400, detail="Too many emails selected")
category = str(payload.get("category") or "selected").strip().lower()
category = re.sub(r"[^a-z0-9_-]+", "-", category).strip("-") or "selected"
selected_dates = sorted({
str(row.get("date") or "").strip()
for row in messages if isinstance(row, dict)
and re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(row.get("date") or "").strip())
})
date_span = ""
if selected_dates:
date_span = selected_dates[0] if len(selected_dates) == 1 else f"{selected_dates[0]} - {selected_dates[-1]}"
export_folder = f"emails-{category}" + (f" ({date_span})" if date_span else "")
zip_buf = io.BytesIO()
used_names: set[str] = set()
exported = 0
skipped = 0
single_export: tuple[str, bytes] | None = None
def _email_filename(row: dict, uid: str) -> str:
date = str(row.get("date") or "").strip()
date = date if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) else ""
subject = str(row.get("subject") or "email").strip()
subject = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]+', "_", subject)
subject = re.sub(r"\s+", " ", subject).strip(" .")[:100] or "email"
safe_uid = re.sub(r"[^A-Za-z0-9_-]+", "_", uid).strip("_") or "message"
prefix = f"{date} - " if date else ""
return _safe_attachment_zip_name(f"{prefix}{subject} - {safe_uid}.eml", "email.eml")
def _fixture_message_bytes(fixture: dict, uid: str, folder: str) -> bytes:
msg = EmailMessage()
sender_name = str(fixture.get("from_name") or "").strip()
sender_address = str(fixture.get("from_address") or "").strip()
sender = email.utils.formataddr((sender_name, sender_address)) if sender_address else sender_name
for key, value in (
("From", sender),
("To", fixture.get("to") or owner),
("Subject", fixture.get("subject") or "(no subject)"),
("Message-ID", fixture.get("message_id") or f"<fixture-{uid}@mail.local>"),
):
clean_value = re.sub(r"[\r\n]+", " ", str(value or "")).strip()
if clean_value:
msg[key] = clean_value
raw_date = str(fixture.get("date") or "").strip()
if raw_date:
try:
parsed_date = datetime.fromisoformat(raw_date.replace("Z", "+00:00"))
if parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
except Exception:
try:
parsed_date = email.utils.parsedate_to_datetime(raw_date)
except Exception:
parsed_date = None
if parsed_date is not None:
msg["Date"] = email.utils.format_datetime(parsed_date)
msg.set_content(str(fixture.get("body") or ""))
for attachment in fixture.get("attachments") or []:
try:
index = int(attachment.get("index"))
source = _fixture_attachment_source(uid, index, owner, folder)
if not source:
continue
_row, raw_attachment = source
content_type = str(raw_attachment.get("content_type") or "application/octet-stream")
maintype, _, subtype = content_type.partition("/")
if not subtype:
maintype, subtype = "application", "octet-stream"
msg.add_attachment(
str(raw_attachment.get("content") or "").encode("utf-8"),
maintype=maintype,
subtype=subtype,
filename=str(raw_attachment.get("filename") or attachment.get("filename") or f"attachment-{index}"),
)
except Exception:
logger.debug("Skipping malformed fixture email attachment uid=%s", uid, exc_info=True)
return msg.as_bytes(policy=email_policy.SMTP)
remote_groups: dict[tuple[str | None, str], dict[str, tuple[str, dict]]] = {}
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for row in messages:
if not isinstance(row, dict):
skipped += 1
continue
uid = str(row.get("uid") or "").strip()
if not uid:
skipped += 1
continue
folder = str(row.get("folder") or "INBOX").strip() or "INBOX"
account_id = str(row.get("account_id") or row.get("account") or "").strip() or None
fixture = _fixture_email_read(uid, folder, owner)
if fixture is not None and not fixture.get("error"):
filename = _email_filename(row, uid)
unique_name = filename
suffix = 2
while unique_name.lower() in used_names:
unique_name = f"{Path(filename).stem}-{suffix}.eml"
suffix += 1
used_names.add(unique_name.lower())
message_bytes = _fixture_message_bytes(fixture, uid, folder)
zf.writestr(f"{export_folder}/{unique_name}", message_bytes)
single_export = (unique_name, message_bytes)
exported += 1
continue
if not re.fullmatch(r"\d+", uid):
skipped += 1
continue
group = remote_groups.setdefault((account_id, folder), {})
group.setdefault(uid, (_email_filename(row, uid), row))
# Fetch original RFC822 data in batches and store it byte-for-byte.
for (account_id, folder), rows_by_uid in remote_groups.items():
rows = list(rows_by_uid.items())
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder), readonly=True)
for batch_start in range(0, len(rows), 20):
batch = rows[batch_start:batch_start + 20]
wanted = {uid: filename for uid, (filename, _row) in batch}
uid_set = ",".join(wanted).encode()
status, msg_data = _imap_uid_fetch(conn, uid_set, "(UID RFC822)")
if status != "OK" or not msg_data:
skipped += len(batch)
continue
found: set[str] = set()
for meta, raw_message in _group_uid_fetch_records(msg_data):
uid = _uid_from_fetch_meta(meta)
if uid not in wanted or not raw_message:
continue
filename = wanted[uid]
unique_name = filename
suffix = 2
while unique_name.lower() in used_names:
unique_name = f"{Path(filename).stem}-{suffix}.eml"
suffix += 1
used_names.add(unique_name.lower())
message_bytes = bytes(raw_message)
zf.writestr(f"{export_folder}/{unique_name}", message_bytes)
single_export = (unique_name, message_bytes)
found.add(uid)
exported += 1
skipped += len(batch) - len(found)
except Exception as exc:
skipped += len(rows)
logger.warning("Bulk email export skipped mailbox folder=%s account=%s count=%s: %s", folder, account_id, len(rows), exc)
if exported <= 0:
raise HTTPException(status_code=404, detail="No selected emails could be exported")
if len(messages) == 1 and single_export:
filename, message_bytes = single_export
return StreamingResponse(
io.BytesIO(message_bytes),
media_type="message/rfc822",
headers={
"Content-Disposition": f"attachment; filename=\"email.eml\"; filename*=UTF-8''{quote(filename, safe='')}",
"X-Exported-Emails": "1",
"X-Skipped-Emails": str(skipped),
},
)
zip_buf.seek(0)
zip_name = _safe_attachment_zip_name(f"{export_folder}.zip", "selected-emails.zip")
logger.info("Bulk email export complete owner=%s selected=%s exported=%s skipped=%s", owner, len(messages), exported, skipped)
return StreamingResponse(
zip_buf,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{zip_name}"',
"X-Exported-Emails": str(exported),
"X-Skipped-Emails": str(skipped),
},
)
@router.get("/inline-image/{uid}")
async def inline_image(
uid: str,
+4 -4
View File
@@ -1,6 +1,6 @@
# Specs DocumentMap
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
This folder is the compact implementation-truth map for humans and coding agents working on Odysseus. Read this file first, then open only the subsystem specs that match the work.
@@ -56,14 +56,14 @@ project documentation.
- [context-building.md](context-building.md): URL/search/RAG/memory/skills/YouTube/email/tool-output context, untrusted wrapping, unavailable context, intent boundaries.
- [search.md](search.md): web search providers, ranking, cache/analytics, URL fetch/content extraction, `src.search`/`services.search` split.
- [documents-rag-uploads.md](documents-rag-uploads.md): uploads, documents, PDF/form handling, personal docs, RAG/vector stores.
- [memory-skills.md](memory-skills.md): memory storage, semantic memory, skill storage/extraction and service-owned audit/evaluation, owner isolation.
- [memory-skills.md](memory-skills.md): memory storage, semantic memory, skill extraction/formatting, owner isolation.
- [research.md](research.md): deep research jobs, synthesis, sources, research library, research UI panel.
- [calendar-tasks-notes.md](calendar-tasks-notes.md): CalDAV calendars, scheduled tasks, reminders, assistant runs, Notes feature state and lifecycle.
- [calendar-tasks-notes.md](calendar-tasks-notes.md): CalDAV calendars, scheduled tasks, reminders, assistant runs, notes/todos.
- [email-contacts.md](email-contacts.md): IMAP/SMTP email, email library, scheduled mail, contacts/CardDAV.
- [gallery-editor-media.md](gallery-editor-media.md): gallery, generated media, image editor drafts, signatures, emoji/font helpers.
- [cookbook-hwfit.md](cookbook-hwfit.md): model downloads, local/remote model serving, hardware detection, fit ranking.
- [speech.md](speech.md): STT and TTS services, routes, settings, optional dependencies.
- [frontend.md](frontend.md): static native-ESM SPA, maintained Notes source/deployment graph, module loading, UI conventions and shared ownership.
- [frontend.md](frontend.md): static SPA, module loading, UI conventions, major JS areas, no-build frontend shape.
- [integrations.md](integrations.md): Codex/Claude scoped APIs, companion pairing, webhooks, external agent access.
- [shell-mcp.md](shell-mcp.md): shell execution, background jobs, MCP manager, built-in MCP servers.
- [settings-admin.md](settings-admin.md): settings, preferences, presets, backup/import/export, diagnostics, admin wipe.
+20 -76
View File
@@ -1,14 +1,12 @@
# Agent Tools
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers agent/tool behavior in:
- `src/agent_loop.py`, `src/clean_agent_preview.py`, `src/turn_contract.py`, `src/agent_evidence.py`, and `src/agent_trace.py`;
- `src/agent_runtime/` authority, resource binding, action journal, durable effects, completion, and context resolution modules;
- `src/containment.py`, `src/process_ownership.py`, `src/process_lifecycle.py`, and `src/browser_identity.py`;
- `src/agent_loop.py`;
- `src/llm_core.py`;
- `src/tool_schemas.py`;
- `src/tool_execution.py`;
@@ -19,7 +17,8 @@ This spec covers agent/tool behavior in:
- `src/tool_capabilities.py`;
- `src/tool_approval_scopes.py`;
- `src/tool_approvals.py`;
- `src/attachment_refs.py` and shared upload lifecycle helpers in `src/upload_handler.py` / `src/tool_utils.py`;
- `src/attachment_refs.py` and shared upload lifecycle helpers in
`src/upload_handler.py` / `src/tool_utils.py`;
- `src/tool_implementations.py`;
- `src/tools/*.py`;
- `src/builtin_actions.py`;
@@ -42,7 +41,7 @@ This spec covers agent/tool behavior in:
`src.llm_core` owns provider payloads, native tool-schema emission, and provider stream parsing. `agent_loop` consumes normalized tool-call events and decides whether and how to execute them.
The legacy loop and the opt-in compact preview share the execution boundary. Agent mode enters through chat routes, including auto-escalation from intent helpers, detached `agent_runs` streaming, resume/stop behavior, and frontend tool-event rendering.
Agent mode enters through chat routes, including auto-escalation from intent helpers, detached `agent_runs` streaming, resume/stop behavior, and frontend tool-event rendering.
Guide-only/no-tools turns are runtime policy, not prompt advice. `src.tool_policy` detects strong latest-turn directives such as guide-only mode, no-tools mode, and explicit requests not to use tools; it builds a `ToolPolicy` that hides schemas, disables known native tools, disables MCP for that turn, skips tool retrieval, suppresses local/workspace context injection, blocks document streaming/teacher escalation, and gives `tool_execution` a final execution backstop.
@@ -65,7 +64,7 @@ Tool registration is split:
When adding, removing, or renaming a tool, update the registry chain, execution dispatch, retrieval text, prompt wording, disabled-tool UI, and tests together.
`src.tool_index.ALWAYS_AVAILABLE` is the retrieval catalog for high-frequency tools such as shell/python, web search/fetch, read/write/edit-file, code-nav, `manage_memory`, `ask_user`, `update_plan`, selected Cookbook serve controls, and `app_api`. Compact prompt/schema assembly selects policy-authorized intent-, skill-, and retrieval-relevant tools; the full-schema surface offers all policy-authorized built-ins and MCP schemas. Selection is not authority.
`src.tool_index.ALWAYS_AVAILABLE` is the retrieval catalog for high-frequency tools such as shell/python, web search/fetch, read/write/edit-file, code-nav, `manage_memory`, `ask_user`, `update_plan`, selected Cookbook serve controls, and `app_api`. Current prompt/schema assembly preserves only selected base tools unconditionally, then adds intent-, skill-, and retrieval-relevant tools so unrelated schemas do not flood small contexts.
## Tool Retrieval And Execution
@@ -77,7 +76,7 @@ Tool retrieval has domain-specific hooks beyond generic similarity: contact quer
Interaction/session/model helper tools are native first-class tools, not prompt-only conventions. `ask_user` and `update_plan` live in `src.agent_tools.interaction_tools`, model delegation/listing helpers live in `model_interaction_tools`, session creation/list/send/manage helpers live in `session_tools`, and `manage_bg_jobs` lives in `bg_job_tools`.
Prompted-tool parsing includes recovery paths for local/provider text leaks: bare JSON after a web-tool mention, OpenAI-style raw `{"function": ...}` payloads, StepFun/Gemma/DSML markup, Hermes/Qwen JSON bodies nested inside `tool_call` wrappers, and `<function_model><function_call>...</function_call><parameters>...</parameters></function_model>` wrappers from local MLX/Exo models. The Qwen bare end marker requires its pipe delimiter so ordinary text cannot terminate a tool block. Non-dict JSON arguments are rejected back to empty args instead of crashing the turn and common `tex` typos normalize to `text`. Shared finite delimiter scanning lives in `src/text_scanning.py`; supported wrapper recovery and bounded scans are not a general linear-time guarantee for all parsing. Executed raw tool JSON is stripped from assistant text afterward; this is still not a general-purpose JSON-command parser.
Prompted-tool parsing includes recovery paths for local/provider text leaks: bare JSON after a web-tool mention, OpenAI-style raw `{"function": ...}` payloads, StepFun/Gemma/DSML markup, Hermes/Qwen JSON bodies nested inside `tool_call` wrappers, and `<function_model><function_call>...</function_call><parameters>...</parameters></function_model>` wrappers from local MLX/Exo models. The Qwen bare end marker requires its pipe delimiter so ordinary text cannot terminate a tool block. Non-dict JSON arguments are rejected back to empty args instead of crashing the turn, common `tex` typos normalize to `text`, and delimiter scans are forward-only so unterminated tool markup cannot drive quadratic rescans. Executed raw tool JSON is stripped from assistant text afterward; this is still not a general-purpose JSON-command parser.
Current call sites include:
@@ -109,7 +108,9 @@ Approval replay injects the sealed first tool result before the resumed model ro
- `src.tool_capabilities` classifies each tool's effects and result integrity. Once external/workspace-untrusted content becomes model-visible, the request/session security context permits only explicitly low-impact tools without interruption and requires exact approval for high-impact, unknown, and arbitrary MCP calls.
- `src.tool_approvals` seals an opaque, expiring exact first action plus server-only selected tools and continuation query to owner, session, origin run, tool content, workspace, capability snapshot, and—when relevant—document id/version/content digest. Chat choices grant the resumed task or the same chat session; both consume the exact first action, task scope bypasses the gate only during that resumed run, and chat scope is reconstructed only from a resolved card bound to the exact session id. The browser never receives selected tools/query and submits only task/chat/deny. Non-chat callers retain single-action behavior; new normal turns and superseding actions retire unresolved approvals without clearing taint.
- Tool results that expose remote or stored untrusted content arm the gate even when their tool status is failed. Content-free failures and server-generated policy/approval placeholders do not. Native/provider tool messages and fenced results carry model-visible untrusted metadata/wrapping instead of relying on prompt wording alone.
- Attachment-bearing document, note, and calendar tools owner-reserve internal upload references before durable writes and fail without mutation when the referenced upload is unavailable.
- Attachment-bearing document, note, and calendar tools owner-reserve internal
upload references before durable writes and fail without mutation when the
referenced upload is unavailable.
- Guide-only/no-tools mode blocks tools before prompt assembly, before execution, and in chat preprocessing paths that would otherwise fetch context or start tool-backed research.
- Plan mode is policy, not prompt advice: mutating native tools are disabled through schema-derived detection plus a static backstop, and write/unknown MCP tools are hidden and runtime-blocked for that turn.
@@ -133,73 +134,12 @@ When an email reader is active, browser chat passes active email metadata and th
## Degraded Behavior
- ToolIndex can degrade to keyword selection when embeddings, Chroma, index warmup, or vector retrieval timeouts fail.
- Agent mode can degrade from native function schemas to prompted fenced-block parsing based on provider/tool-support heuristics. Legacy local Ollama `/v1` and native `/api` endpoints normally use text tools unless explicitly supported. Compact native surfaces force native schemas unless a text-recovery path is selected; configured compact/full modes and the external request contract participate in surface selection, so the legacy default is not universal.
- ToolIndex can degrade to keyword selection when embeddings, Chroma, index
warmup, or vector retrieval timeouts fail.
- Agent mode can degrade from native function schemas to prompted fenced-block parsing based on provider/tool-support heuristics. Local Ollama `/v1` and native `/api` endpoints default to text tools unless the endpoint explicitly advertises `supports_tools`; `gpt-oss` remains text-tool by default unless the endpoint opts in.
- MCP startup failure is non-critical; route/status surfaces expose per-server errors.
- `ODYSSEUS_DISABLE_MCP`, missing `mcp`, uncached browser MCP packages, and per-server disabled tools can remove tools without blocking the app.
- Native browser page execution is unavailable at the common boundary; browser registration and schemas do not override that denial.
## Request Authority, Resource Identity, And Completion
`src.turn_contract` owns immutable request-scoped tool selection and required-read operations. Selection and retrieval never grant permission. `src.agent_runtime.authority` owns server admission independently of offered schemas; `execute_tool_block()` requires an explicit security context and a bound `RequestAuthority`, intersects child authority with its parent, normalizes exact operations, and checks policy before resource resolution or dispatch. Delegated credentials have a separate early denial backstop.
`src.agent_runtime.resources`, `resource_binding`, `owned_resources`, `remote_resources`, and `process_resources` bind operations to server-owned filesystem, owned-record, backend/configuration, process/job, and browser identities. Exact approvals seal these bindings and revalidate them before use; a changed backend, document, path identity, process incarnation, or browser state can refuse replay. Control-plane stores are excluded from native file operations.
`src.agent_runtime.journal` records proposed, normalized, authorized, dispatched, and settled actions. `effects`, `effect_adapters`, and `effect_log` distinguish intent, executor outcome, observed state, and verified effects; a successful receipt alone does not establish resulting state. `completion.with_completion_gate` decorates `stream_agent_loop`, holds answer deltas until the generator unwinds, and checks completion against the server journal/evidence ledger while tool/progress/interaction events remain live. Cancellation cannot emit successful completion. `src.agent_trace` adapts native SSE, typed traces, and imported trajectories into evidence/completion views.
`src.agent_runtime.runtime_selection` is the shared compact-preview eligibility rule. `src.clean_agent_preview` runs the opt-in clean v3 loop when its contract has `clean_compact_v3_preview` selection mode; regular/full-schema and compatibility surfaces retain the legacy loop. Preview schema validation, bounded retry/search/tool budgets, and evidence-based finish checks supplement the common executor rather than replacing its authority.
Current native tools also include `apply_patch`, `get_workspace`, `host_shell`, `private_browser`, `get_weather`, `youtube_tool`, `inspect_media`, `extract_text`, `transcribe_media`, and `search_chats`. Filesystem, subprocess, web, weather, and media handlers own these implementations; `src.media_ingress` owns bounded local attachment ingestion, and OCR/transcription can depend on optional backends.
## Compact Selection And Evidence Recovery
`src.turn_contract` shares broad-web briefing classification with preview. Named private stores, active editor work, concrete local media and explicit operations take precedence over incidental public lookup words. Standalone social turns and full-match supplied-text proofreading/translation (including correction-only colon payloads) offer no tools; compound save/search requests remain separate. A subjectless lookup can request clarification only without conversation referents or supplied workspace/media/editor context. These lexical guards neither resolve arbitrary language nor grant authority. A core tool floor applies only without recognized families, exact selected tools or a sealed read, and is still intersected with inventory/policy; browser is not a core tool.
Explicit discovery-and-page retrieval can select both web tools; workspace artifact requirements can retain bounded file/Python tools alongside web selection. Memory-use prohibitions suppress sealed memory prerequisites. Concrete media routes prefer OCR for exact text, transcription for captions, otherwise inspection; explicit downstream artifacts or personal actions remain distinct. Tool-family selection and multilingual heuristics do not guarantee execution order or semantic understanding.
Preview rejects blank search queries rather than copying conversational text. It preserves requested temporal/news constraints and adds a PDF constraint only when asked. Discovery is bounded separately from source retrieval: no-evidence/repeated cosmetic searches suppress further search while discovered URLs can remain inspectable. Broad briefing recovery permits one breadth expansion after one successful search and fewer than two attempts, within the remaining round budget; an empty second search cannot restart that expansion. One shallow-answer repair uses an 80-word-or-missing-source-URL heuristic, not substantive grounding proof. Breadth work precedes presentation-only citation/content repair.
Retrieval qualification uses bounded body/access/missing-page/repetition heuristics, including embedded article text and a 100-word/12-shingle duplicate detector. A successful retrieval prompts assessment of missing, stale or contradictory evidence rather than forcing completion. Browser fallback proposals cannot override the common page-execution denial. Failed transports can lead to alternative offered search/source retrieval, not an authorized automatic browser session. A rejected search may synthesize only from earlier usable retrieval. Terminal budget fallback preserves up to five deduplicated source strings with explicit preliminary/insufficient-evidence wording, or reports no usable evidence. Synthesized citations are not automatically filled from arbitrary discovered URLs; deterministic link-only answers require a positive whole-request grammar and enough extracted links. Unknown official-source identity is not inferred from PDF extensions or product words.
Native tool choice normalization can make a uniquely offered named search required, narrow schemas before omitting an unsupported provider choice, or use named/auto compatibility for specific DeepSeek/Kimi/Qwen/Ajax families. It is serialization compatibility, not provider acceptance. Wire messages strip server-only provenance and empty assistant placeholders while preserving legitimate tool-call carriers. Sibling tool receipts remain contiguous before bounded untrusted visual blocks; still-image repeat identity includes path, casefolded query and size. Visual defaults retain one current record/three frames with a bounded 1–8 frame control and retire prior tool pixels separately from user uploads.
Artifact completion is target-specific: off-target/scratch writes and generic successful shell receipts cannot satisfy a required artifact or force early synthesis. Empty runner declarations fall back to prompt-inferred artifacts. Directory requests require meaningful files; source-code writes can unwrap leading/trailing Markdown fences for allowlisted suffixes without certifying syntax. Successful workspace paths can display as `/workspace`, without implying universal path redaction. A reserved final synthesis and tightly bounded malformed-call recovery do not create extra execution authority. `turn_contract` and `agent_step` expose required artifacts/calls-used/pending/write-phase fields; these are progress telemetry, not completion proof. Execution timing metrics record attempted canonical tools even on failure.
## Admission And Bounded Execution Details
Chat-scope bearer admission resolves the credential owner and intersects delegated tool grants with server policy; client runtime metadata, retrieval results and prompted tool names cannot widen it. Interactive approval/session options are refused for delegated credentials. Persistent chat grants are server-signed; a normal new turn retires stale pending approvals, while exact first-action replay is one-use. For a non-inherited ungranted launch lacking a backend launch scope, `ToolApprovalStore.create()` may seal exactly one request resource root (or its sealed workspace fallback); inherited children cannot gain this scope. Local reads through delegated credentials, including local `web_fetch`, file-scheme/workspace reads and absolute-path PDF extraction, have an early denial backstop even when the automatic action gate is disabled or bypassed.
`ODYSSEUS_TOOL_APPROVAL_GATE` defaults on; only normalized `0`, `false`, `no` and `off` disable it. The gate does not replace authority/resource checks. Request authority binds owner, request/thread, workspace and admitted operations; child scopes intersect the parent. Owned record revisions, backend/configuration epochs, filesystem identities and process/job generations are revalidated. Captured server producer observations supply evidence; arbitrary tool output or registry membership is not a trusted producer. Expected admission/resource failures become typed denials; unexpected programming errors are not silently converted into authorization success.
Native file tools use the canonical agent workspace and explicit allowed roots, deny control-plane paths and all hardlinked regular files, and validate path/resource identities. `ls`, `glob` and `grep` share one control-plane snapshot per scan; root and bound-resource checks still observe fresh state. Grep normalizes relative-or-basename glob filters before bounded ripgrep batches, validates regex even with no targets, closes stdin and retains a bounded Python fallback with identity revalidation. Empty file writes require explicit clear intent for nonempty existing files; missing/null content is refused, already-empty files can be no-ops, and missing files use exclusive/staged creation without overwriting a competing creator. These checks do not make filesystem operations universally atomic or race-free.
Bounded required-safe reads use an exact sealed operation from the admitted family; a presentation `max_items` limit is not an owner/database filter. Only one eligible active family is warmed and a failed read does not authorize probing alternate families. Bounded model context keeps actual visible records rather than hidden private overflow. Native call identifiers survive only when arguments match exactly; structured turn replacement preserves tool/result pairing. Supplied documents, email, Notes/Todos, skills and workspace instructions remain untrusted reference data.
Compact preview keeps connections alive without replaying executed tools. It permits at most two qualifying context-size retries per turn with reconstructed paired messages and one pre-response transport retry; yielded stream content is not retried. Pre-execution schema hints are freshly generated from offered schemas/arguments (bounded required/type/enum/additional-property/path guidance). Caught execution exceptions instead use fixed public messages and error categories; raw exception details can remain in server logs. Terminal provider errors use fixed public failure events and do not emit a successful done event.
Repeated successful exact calls are suppressed while changed arguments or pagination can proceed; repeated failed exact calls and ignored corrective nudges have separate bounded stop rules. Artifact workflows reserve bounded calls and require actual usable artifacts, not merely directory existence or a tool receipt. Compact interactive rounds clamp to 1–200 with an eight-round fallback, and nonpositive interactive tool budgets use a large sentinel. Validated native workspaces instead clamp to 1–64 rounds with an eight-round fallback and a fixed 32-call budget. Legacy adaptive handling and UI limits are separate. Budgets are checked between rounds and do not interrupt an already-running call. Optional verifier retries and teacher escalation are separate recovery controls; teacher work starts after the parent stream contexts close.
Durable effects distinguish intended mutations, execution outcome and observed state. Claims are persisted before execution; failure to establish a durable claim prevents execution. The newest eligible authorized observation for a resource wins; partial/undecidable evidence cannot borrow an older success, unknown impacts overlap conservatively, and later mutations/new incarnations stale earlier reads. Whole-file postconditions are all-or-none when derivation is unsupported; partial reads are not whole-file digests. Failed reads establish absence only after confined repeated ENOENT checks. Verifier cancellation remains unsettled; changed undeclared targets contradict completion. Only owned running processes survive as running effects; background settlement does not by itself verify output. External execution may finish with disclosed unverified effects, rather than claiming local containment or verified remote state. Scheduled built-ins and arbitrary remote systems are not all covered by this journal.
## Artifact Recovery And Completion Claims
Artifact delivery contracts narrow offered writer schemas on copied schema objects and bind declared targets without treating directory descendants or filename patterns as filesystem confinement. Binary suffixes refuse text-writer delivery, including relevant bridge paths, and prefer an offered capable writer. Empty, malformed, off-contract, or non-materialized output uses bounded shared recovery attempts; pending sibling calls and late results do not justify claiming an artifact exists. Python literal-output analysis is a planning aid, not execution evidence.
Compact tool-call parsing accepts bounded concatenated complete JSON objects with ordered call identities and normal admission checks. Limited trailing tool markup after a complete supported call is inert; arbitrary prose is not an executable call. Artifact research reserves bounded call/time budget for delivery rather than guaranteeing interruption of an already-running tool.
Completion assessment in `src.agent_runtime` distinguishes a usable artifact, a satisfied obligation, and verified execution. File evidence uses exact normalized identity and bounded regular-file reads, rejecting symlink/nonregular aliases rather than trusting basename matches. Partial reads do not prove whole-file verification, late observations do not retroactively verify earlier claims, and executable verifier results take precedence over optimistic prose.
Final presentation can remove unsupported execution, test-count, coverage, or all-fixed assertions while preserving quoted/explanatory text. It does not invent new obligations from fabricated assistant claims. Provider errors, approval waits, and budget exhaustion remain distinct terminal states; a polished answer is not an execution receipt. Child journals remain separate, teacher review follows closed child execution, and incomplete children do not become successful distillation evidence. Explicit no-web filtering cannot be undone by an unrelated memory/toolset early return.
## Parser And Diagnostic Ownership
`src.text_scanning` supplies forward-only shared scans for the existing staged tool grammars, preserving delimiter priority, mandatory terminal-dot captures and legacy newline behavior. The parser does not become a universal linear-time or arbitrary-language recognizer. Binding catches expected input/resource errors as curated denials; programming defects such as AttributeError are allowed to propagate rather than being relabeled as policy refusal.
Filesystem postconditions derive exact requested content from the producer transforms, including edit newline behavior and universal-newline patch prestate, with bounded identity-checked UTF-8 reads. Unsupported derivation clears the complete obligation set instead of verifying a convenient subset. Only a process-bound launch or detachment can attest RUNNING; unrelated returned lifecycle keys, arbitrary containment dictionaries and external acknowledgements cannot manufacture native cleanup or job observations.
## Trusted MCP Attribution
`src/tool_execution.py` strips caller-provided `_odysseus_*` fields from qualified/legacy MCP JSON and parsed tool arguments. Dispatch then injects trusted owner/session context for email and trusted owner for image MCP tools; missing trusted context does not preserve a forged claim. `_call_mcp_tool()` forwards owner through native/unavailable/disconnected direct fallback paths. Image results from qualified dispatch receive promoted image fields. The image MCP server persists the trimmed owner and registers fresh files; this transport attribution does not replace route authorization.
- Global `builtin_browser` disable behavior may not currently match qualified `mcp__builtin_browser__*` tool names.
## Current Gaps
@@ -207,7 +147,11 @@ Filesystem postconditions derive exact requested content from the producer trans
- Agent prompts remain heavy for small local context windows.
- Some AI-control helpers are still globally wired from app startup rather than a narrower service layer.
- Tool registry consistency is manual across handler maps, tags, aliases, schemas, retrieval descriptions, execution dispatch, settings/model routes, and frontend toggles.
- Registry and retrieval consistency still requires coordinated updates across native and MCP surfaces.
- MCP disabled-tool changes can stale-cache tool retrieval because disabled maps are not always an index generation input.
- External MCP output still needs a single central size cap before model re-entry; untrusted-result metadata and the post-external-context action gate now cover the prompt-injection/authorization boundary.
- Auth-disabled/no-login owner propagation is inconsistent between route dependencies and chat/agent execution, so tool-security and native tool storage behavior need dedicated regression coverage.
- Authority, resources, effects, containment, preview, and turn contracts have focused regression suites; live provider and platform behavior still requires separate validation.
- Agent tests mostly cover helpers and targeted regressions, including round-cap
and disconnect cancellation paths, but not an end-to-end fake-LLM
`stream_agent_loop` path with retrieval, native schemas, prompted blocks,
disabled/admin hiding, MCP tools, plan/workspace state, user-time context, and
tool-result SSE.
+38 -42
View File
@@ -1,6 +1,6 @@
# Auth And Security
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -20,7 +20,8 @@ This spec covers current security and trust-boundary behavior in:
- `src/prompt_security.py`;
- `src/url_safety.py` and `src/url_security.py`;
- `src/host_docker_access.py`;
- `src/attachment_refs.py` and upload lifecycle enforcement in `src/upload_handler.py` / `routes/upload_routes.py`;
- `src/attachment_refs.py` and upload lifecycle enforcement in
`src/upload_handler.py` / `routes/upload_routes.py`;
- `src/secret_storage.py`;
- `src/api_key_manager.py`;
- `src/integrations.py`;
@@ -55,7 +56,7 @@ Reserved usernames include request-only sentinels `internal-tool`, `api`, `demo`
Public/auth-exempt surfaces are limited to setup, signup/login/logout/status, feature/settings/integration preset reads, health/version/login, `/static/*`, and task webhook trigger paths. `routes/task/task_routes.py` owns validation of `POST /api/tasks/{task_id}/webhook/{token}` path credentials.
Login issues an `HttpOnly`, `SameSite=Lax` cookie with a seven-day max age when "remember" is enabled. `_secure_cookie()` (`routes/auth_routes.py`) decides the `Secure` attribute: an explicit `SECURE_COOKIES` of `true` or `false` is authoritative, and any other value, including unset and the present-but-empty value docker-compose injects, derives it from the request, marking the cookie `Secure` when the connection scheme or the first `X-Forwarded-Proto` hop is https. TOTP is checked before session issuance. Logout, password changes, user deletion, rename flows, expired sessions, and deleted-user sessions must keep revocation/migration behavior intact.
Login issues an `HttpOnly`, `SameSite=Lax` cookie with a seven-day max age when "remember" is enabled. `_secure_cookie()` (`routes/auth_routes.py:89`) decides the `Secure` attribute: an explicit `SECURE_COOKIES` of `true` or `false` is authoritative, and any other value, including unset and the present-but-empty value docker-compose injects, derives it from the request, marking the cookie `Secure` when the connection scheme or the first `X-Forwarded-Proto` hop is https. TOTP is checked before session issuance. Logout, password changes, user deletion, rename flows, expired sessions, and deleted-user sessions must keep revocation/migration behavior intact.
Deleting a user revokes that user's browser sessions and API-token rows, then the admin delete route invalidates the in-memory bearer-token cache so already-cached tokens stop authenticating.
@@ -67,26 +68,40 @@ Admin promotion/demotion is a live auth flag change through `AuthManager.set_adm
Cookie requests use the real username. Bearer-token requests are stamped as `request.state.current_user = "api"` plus `api_token_owner`, `api_token_scopes`, and token id. Routes that support API-token access must explicitly use `effective_user()` or route-local scope helpers instead of treating `"api"` as an owner.
Internal loopback calls may stamp `current_user = "internal-tool"` or a validated `X-Odysseus-Owner` username. Network/proxy validation for that bypass lives in `app.py`. Native shell/PID and local Cookbook controls add resource-bound admission and reject an internal header or sentinel as human administration. This boundary is surface-specific: common `core.middleware.require_admin()` still admits a matching internal token or sentinel, including MCP configuration routes; producer-side tool policy is a separate check.
Internal loopback calls may stamp `current_user = "internal-tool"` or a validated `X-Odysseus-Owner` username. Network/proxy validation for that bypass lives in `app.py`; `require_admin()` trusts the stamped sentinel or raw internal header and should be used behind equivalent middleware control.
Missing-owner values remain state-dependent at legacy call sites, but new storage-facing code has one normalization contract:
- Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies.
- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode. Native shell and local Cookbook administration additionally require a direct loopback connection without proxy forwarding, cross-site indicators, or an internal-tool header. Remote/proxied anonymous traffic remains denied at these controls. Auth-enabled administration still requires a human admin. This mode trusts local programs as well as the local operator: a headerless loopback request cannot identify which local program sent it. Agent program launches retain inherited networking; this is not protection against hostile local code. Use authenticated mode when local programs are outside that trust. Local Cookbook tools use a separate one-use capability for an admitted exact request/operation/native backend and resolved launch body; that capability cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes.
- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode.
Native shell and local Cookbook administration additionally require a direct
loopback connection without proxy forwarding, cross-site indicators, or an
internal-tool header. Remote/proxied anonymous traffic remains denied at
these controls. Auth-enabled administration still requires a human admin.
This mode trusts local programs as well as the local operator: a headerless
loopback request cannot identify which local program sent it. Agent program
launches retain inherited networking; this is not protection against hostile
local code. Use authenticated mode when local programs are outside that trust.
Local Cookbook tools use a separate one-use capability for an admitted exact
request/operation/native backend and resolved launch body; that capability
cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes.
- Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state.
- SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller.
- `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior.
- `__odysseus_local__` is a valid storage owner but never a login or request sentinel. Adoption is incremental: callers that do not use the storage-owner helper can still expose older `None`/empty/null compatibility behavior.
Authenticated `manage_tasks` mutations require an exact stored task-owner match and reject both cross-owner and legacy null-owner rows. The `owner=None` agent path keeps deliberate auth-disabled single-user compatibility, including unscoped list/create/mutation behavior.
Authenticated `manage_tasks` mutations require an exact stored task-owner
match and reject both cross-owner and legacy null-owner rows. The `owner=None`
agent path keeps deliberate auth-disabled single-user compatibility, including
unscoped list/create/mutation behavior.
Owner-scoped route code should use `require_user()` or equivalent policy before querying per-owner data. Current note CRUD/reorder/reminder routes do this so an auth-enabled request that reaches the route without identity returns `401` instead of falling into single-user/null-owner compatibility behavior.
Scheduled task actions attribute differently again. `_execute_action` (`src/task_scheduler.py`) invokes the action with `owner=task.owner` read from the stored `ScheduledTask` row, so no request and no resolved principal are in flight. These trigger paths converge there: schedule, event bus, manual run (`routes/task/task_routes.py`), the `manage_tasks` agent tool (`src/tools/system.py`), webhook triggers (`routes/task/task_routes.py`), which are unauthenticated by design with the token as the only credential and execute under the stored `task.owner`, and success-chained tasks (`src/task_scheduler.py`), which additionally require the chained target to share `task.owner` and reject cycles. Trigger-side ownership checks use the `if user and task.owner != user` shape, so a falsy caller skips them. Action bodies that reach owner-scoped storage must treat `task.owner` as the authority; route-level `require_user()` never runs on this path.
Scheduled task actions attribute differently again. `_execute_action` (`src/task_scheduler.py:1231`) invokes the action with `owner=task.owner` read from the stored `ScheduledTask` row, so no request and no resolved principal are in flight. These trigger paths converge there: schedule, event bus, manual run (`routes/task/task_routes.py:865`), the `manage_tasks` agent tool (`src/tools/system.py:469`), webhook triggers (`routes/task/task_routes.py:1045`), which are unauthenticated by design with the token as the only credential and execute under the stored `task.owner`, and success-chained tasks (`src/task_scheduler.py:1063-1074`), which additionally require the chained target to share `task.owner` and reject cycles. Trigger-side ownership checks use the `if user and task.owner != user` shape, so a falsy caller skips them. Action bodies that reach owner-scoped storage must treat `task.owner` as the authority; route-level `require_user()` never runs on this path.
## API Tokens And Scoped Integrations
`routes/api_token_routes.py` owns token CRUD and scope normalization. Partial updates preserve existing scopes unless new scopes are supplied, write scopes imply the matching read scopes where applicable, and Cookbook scopes are part of the normalized scope set. `app.py` builds a complete replacement active-prefix map and publishes it by reference, including `app.state`, before verifying bearer tokens with bcrypt. Readers holding an older map are not emptied; this does not guarantee freshest revocation visibility or serialize overlapping refreshes. API-token requests set `request.state.current_user = "api"` plus token owner/scopes.
`routes/api_token_routes.py` owns token CRUD and scope normalization. Partial updates preserve existing scopes unless new scopes are supplied, write scopes imply the matching read scopes where applicable, and Cookbook scopes are part of the normalized scope set. `app.py` caches active token prefix rows and verifies bearer tokens with bcrypt. API-token requests set `request.state.current_user = "api"` plus token owner/scopes.
Current call sites include Codex/Claude scoped APIs, `/api/v1/chat`, webhooks, selected session routes, companion pairing, and external integrations. `/api/codex/*` and `/api/v1/chat` enforce route-local scopes; companion and selected session routes use owner attribution. `companion/pairing.py` can mint chat-scoped tokens outside normal token CRUD.
@@ -122,12 +137,21 @@ Current untrusted surfaces include fetched URLs, web results, emails, memories,
- `src/url_security.py` owns public HTTP(S) validation for integration/API-token supplied URLs. It should fail closed for private IP, loopback, invalid scheme, and unsafe redirect targets.
- `src/url_safety.py` owns local-first outbound URL safety for model endpoints and similar local services. Loopback/LAN can be allowed by default, and private-IP blocking is an explicit caller policy. Strict `block_private=True` also rejects RFC 6598 shared/CGNAT space (`100.64.0.0/10`) explicitly because Python does not classify that range as private.
- `core.log_safety.redact_url()` strips URL userinfo, query strings, and fragments before endpoint URLs enter logs. Model, chat/research endpoint, contact/CardDAV, and similar diagnostics should use this helper instead of logging raw admin-configured URLs.
- `src.webhook_manager` validates webhook URLs at create and delivery time, rejects private/internal targets, disables redirects, and pins delivery to the public IP set that passed validation immediately before the request.
- `src.integrations` owns admin-configured integration base URLs and secret masking. `api_call` accepts only relative paths, rejects link-local/metadata destinations through `src.url_safety`, can additionally block RFC1918/loopback/private targets with `INTEGRATION_API_BLOCK_PRIVATE_IPS=true`, and pins requests to the IP set that passed SSRF validation while preserving the intended Host/TLS identity.
- `src.webhook_manager` validates webhook URLs at create and delivery time,
rejects private/internal targets, disables redirects, and pins delivery to
the public IP set that passed validation immediately before the request.
- `src.integrations` owns admin-configured integration base URLs and secret
masking. `api_call` accepts only relative paths, rejects link-local/metadata destinations through `src.url_safety`, can additionally block RFC1918/loopback/private targets with `INTEGRATION_API_BLOCK_PRIVATE_IPS=true`, and pins requests to the IP set that passed SSRF validation while preserving the intended Host/TLS identity.
- `src.outbound_fetch` owns reusable public-URL classification, validates every redirect hop, rejects private/local resolved addresses, and pins the HTTP connection to the validated public IP while preserving original URL/SNI/Host semantics. `services.search.content` adapts that transport for extraction and caching.
- Path-based tools, upload/document/gallery/signature/generated-image routes, embedding cache paths, and research JSON helpers must stay confined to allowed roots and owner-scoped files. Native file/code-navigation tools also apply a case-insensitive sensitive-path denylist so `grep`, `glob`, `ls`, direct reads, and writes cannot reveal `.env`, SSH/GPG material, private-key filenames, or similar secret paths.
- Durable upload references are owner-reserved before chat/session, document, note, or calendar writes. Cleanup scans every current durable reference surface and fails closed on incomplete discovery or inconsistent upload-index state rather than deleting a possibly live upload.
- File-backed SQLite startup restricts `app.db` and existing rollback/WAL/SHM sidecars to `0600` on POSIX after resolving the real path from the parsed engine URL. Windows, in-memory, and non-SQLite databases are excluded, and failed POSIX restriction is logged as a secret-file warning.
- Durable upload references are owner-reserved before chat/session, document,
note, or calendar writes. Cleanup scans every current durable reference
surface and fails closed on incomplete discovery or inconsistent upload-index
state rather than deleting a possibly live upload.
- File-backed SQLite startup restricts `app.db` and existing rollback/WAL/SHM
sidecars to `0600` on POSIX after resolving the real path from the parsed
engine URL. Windows, in-memory, and non-SQLite databases are excluded, and
failed POSIX restriction is logged as a secret-file warning.
- Secret-like DB columns use `EncryptedText` or `src.secret_storage`. Email passwords and Google OAuth mail tokens are encrypted manually in `EmailAccount` string columns; Google OAuth state is HMAC-signed and callback writes are owner-checked before token storage. `src.api_key_manager` keeps provider API keys encrypted in `data/api_keys.json`, writes by loading the raw encrypted dict so saving one provider does not rewrite other providers' keys as plaintext, and restricts local key-file permissions where the platform supports chmod. Vault state in `data/vault.json` is a chmod-restricted JSON secret store, not Fernet-encrypted DB storage. Do not log or return decrypted secrets except for intentional admin vault retrieval flows with audit/reason checks.
- `.env` files are secrets-only inputs and should not be read or printed during agent work.
@@ -146,39 +170,11 @@ Host Docker socket access is a high-trust admin/deployment choice, not a normal
- Legacy migrations claim null-owner SQL/JSON data for the primary admin when possible, and startup repeats a null-owner sweep hourly. Remaining null-owner rows are surface-specific compatibility data that must be deliberately included, no-oped for single-user mode, or rejected for strict ownership gates.
- `.env` is loaded with `utf-8-sig`, so Windows BOM auth flags still parse.
## Server Admission And Model Execution
`src.agent_runtime.authority.RequestAuthority` is server-owned admission separate from schema availability or intent selection. `execute_tool_block()` requires explicit security context, verifies owner/session/workspace binding, intersects delegated child authority, applies policy denial before approval/bridge dispatch, and resolves typed backend/filesystem/owned/process/browser resource identities. Exact approvals seal and revalidate these bindings; task/chat taint grants cannot manufacture absent backend or resource authority.
Native filesystem operations use `src.path_confinement` and `src.agent_runtime.resources` to reject sensitive/control-plane paths, including runtime effect stores and aliases. Native model shell/Python paths use enforcing containment with required filesystem, process-tree, and wall-clock dimensions; missing required enforcement fails before spawn. This is distinct from direct operator/admin HTTP shell access and declared external execution bridges. Network and resource-limit dimensions are not automatically promised by the default requirements.
`core.auth.AuthManager.reset_user_password()` permits an admin to reset a non-admin account and revokes that account’s sessions. Admin targets and non-admin callers are refused.
## Scoped Admission And Public Error Details
Browser chat/session/history bearer support requires chat scope and resolves the token owner. Delegated callers cannot approve actions, inherit browser chat grants, supply raw session credentials or bypass route validation. Resolved persistent chat grants are HMAC signed and checked; client metadata removes authority-bearing fields. Parent/child task and teacher authority intersects, and local file/workspace/PDF reads have delegated early denials independent of the optional action gate. The automatic high-impact gate defaults on, disabled only by explicit normalized false values; stale threat-model prose describing unrestricted model filesystem access is not the current native contract.
Local-first URL checks classify IPv4-mapped addresses by embedded IPv4 and decode exactly `64:ff9b::/96` with strict embedded-target policy, even when the caller allows private services. Other translation prefixes receive no inferred exemption. IPv6 loopback is checked before reserved-address denial and follows the configured private policy. Mail connection helpers validate the resolved address set and pin actual TCP connection; scholarly metadata validation does not inherit that stronger transport claim.
Host/client run/read/write/patch/poll/cancel bridge clients disable environment proxy trust and carry exact admitted authority, while external execution remains unattested local containment. MCP backend/config and Vault unlock/catalog identities are epoch/revision bound. Native browser page actions and raw global Playwright calls are currently denied unconditionally; metadata-only `session_info` does not restore them. Native filesystem root checks reject NUL/CRLF and all hardlinked regular files, but do not imply every OS path operation is atomic.
Preview validation may expose bounded schema-derived argument guidance before dispatch; caught execution exceptions use fixed public errors/categories. Raw exception text can remain in server logs. Transport/provider terminal errors are distinct from successful completion, and public assertion filtering is not a general secret scrubber. Upload reference reservations, scoped owners and runtime credential custody continue to apply independently.
## Configuration And Generated-Media Boundaries
Auth-route app configuration uses `_require_admin_user()` to honor explicit disabled-auth operator mode for settings/features, document-style extraction and integrations; user-store administration still requires an admin identity. Settings GET exposes full settings to the disabled-auth operator, while auth-enabled anonymous/non-admin reads remain scrubbed. This is a trusted deployment mode, not an anonymous multi-user grant.
Generated-image serving checks filename syntax before identity, then `effective_user()` (cookie or API-token owner) before disk access. A live process-local registration, gallery owner, linked session owner, or owned-chat metadata reference can authorize access; absence/mismatch returns 404 and database verification errors return 503. Disabled auth skips ownership checks. Responses use private revalidation and nosniff headers. Tool argument parsers strip caller `_odysseus_*` fields before server context injects trusted image/email owner/session attribution; see `gallery-editor-media.md` and `agent-tools.md`.
## Upload Route Authorization
Upload routes distinguish disabled-auth operation from unavailable/unconfigured auth infrastructure: explicit no-login bypasses owner gates; auth-enabled missing manager is 503, unconfigured manager 401 and missing/sentinel identity 403, without loopback fallback. Interactive admin override remains available for non-owned/legacy ownerless files. Delegated API credentials require a real token owner, matching non-null file ownership and an appropriate scope; even an admin-owned token has no cross-owner or ownerless override. Read scopes are any of `chat`, `documents:read`, `documents:write`; upload creation/OCR mutation requires `chat` or `documents:write`. See `documents-rag-uploads.md` for transition behavior.
## Current Gaps
- Direct operator/admin HTTP shell execution remains powerful; model-code containment and typed admission must not be generalized into a sandbox claim for every administrative surface.
- There is no shell/filesystem sandbox for admin tools.
- Token scopes remain coarse for some surfaces.
- Auth policy, scoped tokens, and request authority have focused regression tests; full middleware composition and live proxy deployments still need separate validation.
- `app.py` AuthMiddleware lacks direct regression coverage for bearer-token state/cache behavior, trusted-loopback proxy-header rejection, and internal-tool owner stamping.
- Codex/Claude scoped route enforcement still needs stronger regression coverage.
- `THREAT_MODEL.md` still has stale token-scope and `/api/v1/chat` SSRF gap text that should be reconciled with current route validation.
- The Default/Local owner contract is canonical but only incrementally adopted; route helper `""`, chat/agent `None`, SQL/JSON null-owner compatibility, and calendar fallback owner behavior still need domain-by-domain migration decisions.
+20 -34
View File
@@ -1,6 +1,6 @@
# Calendar, Tasks, And Notes
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -18,7 +18,7 @@ This spec covers calendar, reminders, tasks, assistant runs, and notes in:
- scoped Codex wrappers in `routes/codex_routes.py`;
- database models `CalendarCal`, `CalendarEvent`, `ScheduledTask`, `TaskRun`, `Note`, and `CrewMember`;
- direct DB CLIs `scripts/odysseus-calendar`, `scripts/odysseus-notes`, and `scripts/odysseus-tasks`;
- frontend modules `static/js/calendar.js`, `static/js/calendar/*`, `static/js/tasks.js`, the `static/js/notes.js` compatibility facade and maintained `frontend/src/features/notes/` graph deployed under `static/js/features/notes/`, and `static/js/assistant.js`;
- frontend modules `static/js/calendar.js`, `static/js/calendar/*`, `static/js/tasks.js`, `static/js/notes.js`, and `static/js/assistant.js`;
- tests covering calendar routes/utilities, CalDAV, recurrence, timezone handling, scheduler behavior, task webhooks, notes CLI/tool behavior, and task CLI behavior.
## Calendar
@@ -53,8 +53,6 @@ Cookbook serve scheduling crosses this domain. The Cookbook UI creates `cookbook
`routes.task.task_routes` owns task CRUD, status, manual run/stop/cancel, pause/resume, owner-scoped run/activity history, metadata, onboarding defaults, cache clearing, parse endpoints, and webhook-token regeneration. `app.py` imports the canonical package path; `routes/task_routes.py` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Chained-task `then_task_id` values are validated as same-owner relationships on create/update, and scheduler execution also rejects cross-owner or cyclic chains.
`src.tools.system.do_manage_tasks()` translates structured schedules: `day_of_month` is an integer 1–31 for monthly tasks, while recurring `weekdays` names plus `scheduled_time` in UTC `HH:MM` become a cron expression. Conflicting day/cron inputs are rejected. One-off `scheduled_date` values are parsed to naive UTC and must be future dates; recurring schedules reject supplied one-off dates, and invalid cron expressions are refused. A time-only edit of an existing five-field cron rewrites its minute/hour fields while preserving calendar fields and recomputes `next_run`; it does not merely update the legacy display clock.
Task webhook paths are auth-exempt at the app middleware layer only for `/api/tasks/{task_id}/webhook/{token}`. The route still validates active task state plus task-specific webhook token before dispatch.
Task runtime behavior:
@@ -67,16 +65,25 @@ Task runtime behavior:
- event-bus triggers persist counters and `next_run` before scheduler handoff;
- the in-process scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`, and multiple enabled app processes can double-run work.
- action tasks with `run_local`, `run_script`, `ssh_command`, or
`cookbook_serve` are admin-only. `routes.task_routes` enforces this on create/update/manual run and hides those actions from `/meta/actions` for non-admin owners; webhook and scheduler execution pause the task and clear `next_run` if an admin-only action belongs to a non-admin owner.
`cookbook_serve` are admin-only. `routes.task_routes` enforces this on
create/update/manual run and hides those actions from `/meta/actions` for
non-admin owners; webhook and scheduler execution pause the task and clear
`next_run` if an admin-only action belongs to a non-admin owner.
- background LLM task execution uses the background workload path, and the
scheduler can abort/cancel active in-process task runs when foreground browser activity appears.
scheduler can abort/cancel active in-process task runs when foreground browser
activity appears.
- `tidy_research` scans all persisted research files because broken JSON has no trustworthy owner stamp, so it runs only for admins or the explicit auth-disabled single-user operator and refuses regular/pre-setup callers before enumeration.
`routes.assistant_routes.py` owns crew/assistant settings and run-status surfaces that use the scheduler. `TaskScheduler.ensure_assistant_defaults()` currently seeds the personal assistant crew member and pinned assistant session, but no longer auto-creates Morning/Midday/Evening check-in tasks. Existing crew-linked check-in tasks are still rendered and managed when present.
## Notes And Reminders
`routes.note.note_routes` owns notes/todos/reminders, and `app.py` imports that canonical path. `routes.note_routes` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Notes are SQLAlchemy `Note` rows and can include due dates, ordering, images, repeat state, AI classification, source/session provenance, and agent session linkage.
`routes.note.note_routes` owns notes/todos/reminders, and `app.py` imports that
canonical path. `routes.note_routes` replaces its module entry with the
canonical module for legacy import and monkeypatch compatibility. Notes are
SQLAlchemy `Note` rows and can include due dates, ordering, images, repeat
state, AI classification, source/session provenance, and agent session
linkage.
Notes CRUD/reorder/reminder routes resolve the acting owner through `require_user()`: auth-enabled anonymous requests fail closed before hitting owner-scoped queries, while documented no-login/single-user modes still resolve to the compatibility owner path.
@@ -98,15 +105,17 @@ Reminder dispatch is Note-owned:
Email/ntfy failures degrade into channel result fields rather than blocking every reminder path. ntfy and generic webhook reminder URLs run through outbound URL safety checks, with `REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS` controlling whether private/LAN targets are allowed. ntfy notification titles are converted to ASCII with replacement and capped at 200 characters before entering HTTP headers. Reminder dedupe uses owner-scoped cache files under `data/`.
`src.builtin_actions.action_ping_notes()` counts delivery only when `dispatch_reminder()` reports success for the configured browser/email/ntfy/webhook channel. Typed receipts in `data/note_pings_<owner-slug>.json` record time and channel. A browser fallback or legacy timestamp does not prove external delivery: the next eligible scan retries the configured external channel without queueing the recent browser fallback again. The scanner reloads dispatcher-owned receipts before pruning so it cannot replace a successful channel receipt with a timestamp; total delivery failure is not checkpointed. Retries occur only within 90 seconds of the due time, while recent matching-channel receipts suppress delivery for 25 minutes. Existing retry tests cover webhook/ntfy failures, legacy receipts, browser dedupe, total failure, and receipt-preserving pruning; live channel delivery remains separate validation.
## Agent, Codex, And CLI Surfaces
`do_manage_tasks`, `do_manage_notes`, and `do_manage_calendar` own agent-side writes. `do_manage_calendar` supports batch event creation plus list range aliases (`start`, `start_time`, `start_date`, `range_start`, `from`, `dtstart`, `since`, and matching end aliases), calendar name/short-id lookup, importance/tag aliases, and reminder offsets expressed as numbers, minute/hour words, or common abbreviations such as `min`/`mins`/`hr`/`hrs`. If a model supplies a loose `query`, `date_range`, or `range` without explicit start/end datetimes, `list_events` returns an error asking the caller to resolve the range and call again instead of guessing. Event classification reads `Memory.text` for personal context before LLM classification. `src.tool_index` encodes the reminder policy that notes/todos own reminders while calendar events own time blocks.
Agent native tool owner handling is not uniform today. `do_manage_tasks()` filters lists only when `owner` is truthy and creates tasks with the passed owner, so `owner=None` can create legacy/null-owner tasks. For authenticated/non-empty owners, edit/delete/pause/resume/run require an exact stored owner match and reject both cross-owner and null-owner rows; `owner=None` retains single-user compatibility. `do_manage_notes()` list/query behavior distinguishes `None` from `""`, with `None` acting as broader single-user compatibility while `""` filters to empty-owner rows in some paths. `do_manage_calendar()` query helpers filter only when owner is not `None`, while calendar creation routes through the calendar fallback owner for default calendars. These are compatibility behaviors, not a cross-user sharing model.
Note and calendar route/tool writers owner-reserve any canonical internal upload references in content, checklist/color/image fields, descriptions, and locations before their database writes. Missing or wrong-owner uploads fail the write instead of creating a dangling durable reference; reservations serialize with upload cleanup.
Note and calendar route/tool writers owner-reserve any canonical internal upload
references in content, checklist/color/image fields, descriptions, and
locations before their database writes. Missing or wrong-owner uploads fail the
write instead of creating a dangling durable reference; reservations serialize
with upload cleanup.
Chat forwards browser timezone offset and IANA timezone name so natural-language note/calendar tools can anchor dates to the user clock. A valid IANA zone wins over the fixed offset for current-time/DST reasoning; invalid or absent names fall back to the offset and then server-local/UTC compatibility behavior. Chat can auto-promote note/calendar/reminder intents to agent mode.
@@ -162,32 +171,9 @@ Existing coverage is strongest around CalDAV URL hardening/writeback, client cle
Route-level coverage is thinner for full calendar route behavior, task CRUD/security/run controls, live webhook token dispatch, notes owner CRUD/reminder delivery, assistant defaults/run status, event-bus triggers, Codex todo/calendar scopes, and frontend panel wiring.
## Calendar Reminder And Invitation State
Calendar event responses now include reminder note id, due time, minutes-before, and source-email provenance. Create/update accept `reminder_minutes`; an explicitly supplied null clears the reminder, while omission preserves it. Reminders are owner-scoped `Note` todos with calendar label/source. Passed events skip creation, and a reminder whose intended time passed is clamped to now. Legacy matching uses generated title plus due time rather than an event UID; deleting reminders uses the title/source/owner match, so similarly named events remain a compatibility-sensitive boundary. `GET /api/calendar/events/{uid}` resolves recurring occurrence IDs back to the owner-visible base event.
`src/email_calendar_import.py` owns invitation revision state through `EmailCalendarInvitation`: identity hashes owner, normalized sender, ICS UID, and optional recurrence id instead of letting untrusted ICS UIDs address arbitrary local events. Sequence/DTSTAMP ordering suppresses stale replays, cancellation wins equal-version ties and leaves tombstones, and detached occurrences exclude their original series instance. Fixed striped file locks serialize imports across processes. Unsupported reply methods are ignored, and `THISANDFUTURE` revisions require a replacement series.
Task create/update persist sealed `request_authority_json`; execution binds that authority to LLM/research work rather than deriving permission from task output. Task endpoint resolution uses owner-visible registered endpoint bases or their exact derived chat URL before runtime credentials are attached; scheme, host, effective port and full trimmed path must match, with no userinfo, parameters, query or fragment. Legacy shared endpoint visibility is retained. The scheduler helper catches credential resolution exceptions and can continue with empty headers, so this is not a universal no-network-on-resolution-failure guarantee. Authority/effect contracts are owned by `src/agent_runtime`, not the scheduling layer.
## Scheduler Cache And Native Task Details
Scheduler `_cached` requests share one pending future per key with a TTL. Waiters shield the shared work from their own cancellation; completion/failure removes only the matching pending future, and failures can retry rather than being cached as success. Endpoint discovery separately caches successful empty Tailscale results, while discovery failures do not become successful empty cache entries.
Task/assistant endpoint updates accept registered owner-visible routes, canonicalize empty values to no override and reject invalid routes. Local shell/script/Cookbook scheduled actions require their sealed native backend capability; missing capabilities fail closed and raw external/SSH execution cannot borrow it. Headless task/background continuations close their streams in the originating task, preserve authority, honor replacement final responses and distinguish provider failure from successful completion.
`NotificationLog` GET is owner-scoped and non-consuming, defaults to 200 rows and clamps to 1000. Notification insert title/body limits are 200/2000 characters; notification text does not grant task authority. Calendar reminder matching is legacy title/source/owner-based rather than UID-linked: nearest eligible due time within zero to seven days of the base event start can match, while deletion removes all same-title matches rather than discriminating by exact alarm time. Negative offsets clamp to zero, passed events do not create reminders, passed alarm time clamps to now, explicit null clears and omission preserves.
## Notes Frontend State And Lifetimes
The canonical Notes composition entry owns one `createNotesState()` instance, injected HTTP/storage/notification ports, application workflows and UI factories. Domain modules own note rules and local-time recurrence. Application modules own query/order, optimistic submission/card/checklist/deletion, selection, drafts, agent actions and reminders; UI modules delegate mutations and transport through those operations. The legacy facade preserves default/named open/close/toggle/new/openNote/badge methods and aliases, including named-only reminder-dot dismissal, without exposing mutable state.
Background startup is idempotent: after three seconds it loads active notes, then starts a 30-second reminder interval and immediate check. Close/minimize/force-close dispose panel resources but leave that page-owned interval running. Scans use the current shared note collection, skip archived/date-only/fired notes, fire within the previous minute, and advance or mark older reminders; archived-only/error-cleared lists can still defer active reminders. Server synthesis has a 1.5-second local-notification fallback, with toast/highlight persistence. This browser path remains separate from backend delivery receipts.
Drafts persist synchronously on input/change and serialize existing-note network autosaves after 500 ms; successful saves clear only the matching draft, while failure/new/drawing drafts remain recoverable. Canvas bitmaps are not draft JSON. Explicit drawing save awaits PNG upload before optimistic submission. Selection dispatches `odysseus:notes-context-selected` synchronously and exits only when the consumer sets `accepted`; chat retains context/session authority. Closing releases owned portals/listeners/gestures/observers, and late fetch/masonry work checks panel connectivity/open state. It does not add universal async generation guards: fetch remains last-completion-wins, optimistic creation replaces the first remaining temporary note, and bulk actions can swallow individual failures before refetch/reporting requested counts.
## Current Gaps
- CardDAV still needs URL hardening parity with CalDAV; CalDAV now resolves hostnames during validation and revalidates writeback URLs.
- `do_manage_notes()` should match HTTP note-route owner behavior for legacy null-owner notes.
- Auth-disabled agent tools can produce or read broader owner scopes than route handlers because they receive `owner=None`; tasks, notes, and calendar need aligned policy/tests.
- Task webhook tests should keep exercising live route token behavior and
+41 -38
View File
@@ -1,19 +1,20 @@
# Chat
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers current chat behavior in:
- `routes/chat_routes.py` and `routes/chat_helpers.py`;
- `routes/session_routes.py` and canonical `routes/history/history_routes.py`, with `routes/history_routes.py` as a compatibility shim;
- `routes/session_routes.py` and canonical `routes/history/history_routes.py`,
with `routes/history_routes.py` as a compatibility shim;
- `src/chat_helpers.py`;
- `src/agent_runs.py` and `src/background_tool_jobs.py`;
- `src/turn_contract.py`, `src/request_models.py`, and `src/agent_runtime/` request authority/runtime selection/context resolution;
- `src/agent_runs.py`;
- `src/chat_handler.py` and `src/chat_processor.py`;
- `core/session_manager.py` and `core/models.py`;
- `src/attachment_refs.py` and `src/upload_handler.py` for durable attachment references and write reservations;
- `src/attachment_refs.py` and `src/upload_handler.py` for durable attachment
references and write reservations;
- `src/context_budget.py`, `src/context_compactor.py`, and `src/topic_analyzer.py`;
- `src/foreground_model_routing.py`, `src/tool_approval_scopes.py`, `src/tool_approvals.py`, and `src/tool_capabilities.py`;
- `routes/workspace_routes.py` for workspace selection support;
@@ -40,10 +41,16 @@ Runtime behavior:
- browser chat sends `X-Tz-Offset` and an IANA timezone name; request-local helpers prefer a valid IANA zone for DST-aware current-time reasoning, then fall back to the fixed offset;
- browser chat can send a selected workspace path; route code only resolves it for admin/single-user flows, validates it as an existing directory, and forwards it so agent file/shell tools are confined by `src.tool_execution`;
- stream callbacks can outlive a deleted session, so persistence must fail closed instead of recreating orphan messages;
- message metadata carries timestamps, metrics, tool events, sources, hidden thinking/reasoning text when providers expose it separately, context-trim metrics, structured attachment references, and related UI state;
- message metadata carries timestamps, metrics, tool events, sources, hidden
thinking/reasoning text when providers expose it separately, context-trim
metrics, structured attachment references, and related UI state;
- metadata preserves requested and actual reply models and endpoints, per-round route transitions, and answering-route cost attribution; stable session ids remain available so prompt/sequence-memory and KV-cache paths can address the same conversation consistently;
- multimodal content can be a list of content blocks for the live provider call, while persistence collapses raw media into readable text and stable attachment-reference lines;
- agent streams forward explicit round-cap, tool-budget, repeated-tool-loop, and intent-without-action guard events so the frontend can distinguish a controlled stop from a stalled response.
- multimodal content can be a list of content blocks for the live provider call,
while persistence collapses raw media into readable text and stable
attachment-reference lines;
- agent streams forward explicit round-cap, tool-budget, repeated-tool-loop,
and intent-without-action guard events so the frontend can distinguish a
controlled stop from a stalled response.
`src.agent_runs` owns detached in-memory stream runs, replay buffers, replacement cancellation, resume subscribers, explicit stop, and terminal-buffer eviction. Closing the SSE connection does not necessarily stop generation. `static/js/chat.js` can live-resume a still-running detached stream through `/api/chat/resume/{session_id}`; rich responses reload from DB for canonical rendering. Detached runs are process-local and do not survive server restart.
@@ -74,15 +81,30 @@ Current call sites include:
- deep research orchestration in `src/research_handler.py`;
- compare entry points in canonical `routes/compare/compare_routes.py` and frontend compare modules.
Agent-mode tool access is gated in layers. Chat route toggles and privileges build a disabled-tool set; incognito and compare mode remove persistence-heavy or UI-breaking tools; `src.action_intents.message_needs_tools()` provides conservative regex auto-escalation hints; `src.agent_loop`, `src.tool_security`, `src.tool_execution`, and internal loopback validation remain server-side enforcement owners.
Agent-mode tool access is gated in layers. Chat route toggles and privileges
build a disabled-tool set; incognito and compare mode remove persistence-heavy
or UI-breaking tools; `src.action_intents.message_needs_tools()` provides
conservative regex auto-escalation hints; `src.agent_loop`,
`src.tool_security`, `src.tool_execution`, and internal loopback validation
remain server-side enforcement owners.
`allow_bash` and `allow_web_search` can be read from the JSON request body for browser chat posts that do not submit traditional form fields.
Web search tools are per-turn explicit opt-in. Either `allow_web_search=true` or `use_web=true` can enable `web_search`/`web_fetch`, but an explicit `allow_web_search=false` wins over `use_web=true` and keeps those tools disabled. Explicit latest-turn web-search intent can still auto-escalate into agent mode and narrows the available tool set toward `web_search`/`web_fetch`, but it no longer re-enables web tools after an explicit denial or global disable.
Web search tools are per-turn explicit opt-in. Either `allow_web_search=true`
or `use_web=true` can enable `web_search`/`web_fetch`, but an explicit
`allow_web_search=false` wins over `use_web=true` and keeps those tools
disabled. Explicit latest-turn web-search intent can still auto-escalate into
agent mode and narrows the available tool set toward `web_search`/`web_fetch`,
but it no longer re-enables web tools after an explicit denial or global
disable.
Guide-only/no-tools requests build an effective tool policy before preprocessing and agent dispatch. That policy suppresses tool-backed preprocessing/background extraction/research, disables schemas and MCP for the turn, and is still enforced by `src.tool_execution` if a model emits a tool call anyway.
When route context is trimmed without full compaction, chat emits a `context_trimmed` SSE event and carries before/after message/token counts into metrics. Provider reasoning/thinking deltas are streamed for live UI handling but kept out of the visible saved assistant content and stored in metadata when available.
When route context is trimmed without full compaction, chat emits a
`context_trimmed` SSE event and carries before/after message/token counts into
metrics. Provider reasoning/thinking deltas are streamed for live UI handling
but kept out of the visible saved assistant content and stored in metadata when
available.
## Attachments
@@ -90,11 +112,12 @@ When route context is trimmed without full compaction, chat emits a `context_tri
Attachment-only sends are valid. Missing or unauthorized ids are skipped during preprocessing, while a missing/wrong-owner durable reference aborts a message/history replacement before existing transcript rows are removed. Upload failures keep pending files for retry, unsupported media can degrade to text markers, optional Office/PDF/VL dependencies can emit extraction banners, Office attachments can create markdown documents when extracted server-side, and fillable-PDF auto-document failures fall back to normal PDF extraction. `chat_messages.content` and FTS do not retain provider data URLs; structured references stay in metadata for reloads. Chat does not own upload bytes or durable document storage; it requests document/upload behavior from those subsystems.
Frontend resend normally replaces from the selected user turn: it truncates server history before removing that turn and later DOM/thread state, resolves the persisted message id with index fallback, and aborts if truncation fails. Explicit append-only resend appends a fresh copy; attachments are retained through the respective path. AI-message delete prompts before removing the AI response plus preceding user turn. Desktop Enter submits; mobile Enter inserts a newline unless another platform-specific send control is used.
Frontend chat distinguishes normal resend from regenerate-from-here: normal resend appends a fresh user copy and carries upload IDs where available, while regeneration truncates from the selected point. AI-message delete prompts before removing the AI response plus preceding user turn. Desktop Enter submits; mobile Enter inserts a newline unless another platform-specific send control is used.
The Notes panel can attach selected Note/Todo IDs as reference context for a chat send. `static/js/chat.js` keeps selection in memory per chat session, transfers a pending selection when a new session is materialized, restores the indicator on session switches, and lets the user clear it. A send captures the selected IDs for its current session and includes `selected_note_ids` in the streaming form. This is separate from uploaded attachments and is not durable session storage. The server reloads exact-owner notes and wraps their text as untrusted context; validation and formatting are owned by [context-building.md](context-building.md).
Native document tool outputs can open or refresh the document editor from tool-result metadata, so the UI can recover if a later `doc_update` stream event is missed. The chat renderer also hides raw/incomplete leaked tool JSON and document fences from normal transcript text.
Native document tool outputs can open or refresh the document editor from
tool-result metadata, so the UI can recover if a later `doc_update` stream event
is missed. The chat renderer also hides raw/incomplete leaked tool JSON and
document fences from normal transcript text.
When untrusted external/workspace content has entered the agent context, high-impact tool calls pause as exact approval cards instead of executing. The browser can allow the rest of the interrupted task, allow this chat session, or deny; it submits only the opaque id/decision with an empty control-plane message and does not mutate the composer. The server restores the sealed first action plus private selected tools/query, revalidates policy and document freshness, consumes the first action, and resumes without persisting a synthetic user message. Task scope ends with that resumed run. Chat scope persists the resolved card and marks later context only for that exact session; forks do not inherit it. A normal message retires an unresolved card while preserving taint.
@@ -104,7 +127,7 @@ When untrusted external/workspace content has entered the agent context, high-im
The scoped API-token chat surface is `/api/v1/chat`. Browser chat routes can receive bearer-auth state from middleware, but route code must not assume `"api"` is a durable owner; API-token support requires explicit scope checks and token-owner attribution.
Incognito skips durable user and assistant message writes, supplies the current user turn ephemerally without duplicate insertion, avoids loading saved transcript history, and disables memory/skill/history context paths. This is a message-history contract, not a guarantee that every route, session header or auxiliary operation performs no write.
Incognito disables memory, skill, and chat-history tools and skips assistant DB persistence, but current user-message persistence and later cleanup are not a strict no-write guarantee. Treat incognito changes as security-sensitive until that contract is clarified.
## Search Boundary
@@ -121,31 +144,11 @@ Incognito skips durable user and assistant message writes, supplies the current
- Docker/native endpoint differences are owned by runtime/model setup, but chat sessions depend on the saved endpoint URLs and headers.
- Copying a response from the UI copies the displayed answer text and omits hidden reasoning/thinking segments.
## Turn Preparation And Durable Background Results
Chat preparation derives request authority separately from tool inventory. `src.turn_contract` selects capability families and required reads for compact/native routes; exact approval continuations, the TUI surface, and full-schema routes opt out of that selection path. Endpoint/model tool modes and the clean-route aliases (`cleanv3`, `preheret`) influence routing; the shared runtime-selection helper determines whether both context preparation and execution use compact v3. Full-schema models retain the complete enabled inventory subject to server permission gates.
Sessions persist independent memory-extraction, memory-injection, and skill-injection toggles, thinking mode, temperature/max-token overrides, workspace `cwd`, endpoint id, accumulated token totals, and cost. Session hydration restores these values. Generated images are preserved when a session is deleted unless image deletion is explicitly requested.
`src.background_tool_jobs.BackgroundToolJobs` stores tool-work origin and once-only chat delivery in `BackgroundToolJob` SQL rows. This is separate from process-local detached SSE buffers and disk-backed shell jobs. Result context is reconstructed from background metadata; a durable completion must remain attributed to its originating owner/session.
## Session Preparation Details
Credential recovery selects the session's bound enabled URL-matching endpoint before resolving its provider-auth id; a deleted/disabled bound endpoint does not trigger selection of another account. Legacy unbound recovery chooses the oldest enabled owner-visible URL-matching `ModelEndpoint` by creation time/id and persists `endpoint_id` before resolving runtime headers. Client/TUI context is bounded untrusted metadata, not admission, workspace grants or permission to execute supplied verifier commands. Context-info exposes bounded tool/skill/AGENTS path inventory rather than instruction bodies; host existence details are admin-only.
Header persistence retries selected SQLite locked failures with bounded backoff and can continue in memory after exhaustion; it does not guarantee durability. Session listing uses owner-filtered unarchived database rows, hides synthetic trace sessions and treats empty sessions according to persisted messages/documents/images or live state. Synthetic `sft_` trace capture is enabled by default unless opted out; prefix selection and trash/tombstones are not data erasure guarantees. Auto-title uses a bounded utility call and a short word-based fallback.
Active document state is owner-checked and chat-bound. An explicitly empty active-document state disables fallback, minimized editors do not resurrect context, and session switches preserve the exact active document rather than selecting the first cached document. A stream run id identifies streamed UI replacement and differs from nested execution-journal identity. Structured turn replacement and terminal error events preserve partial content without claiming a successful completion. Background tool delivery attribution is separate from provider success and disk-backed shell result delivery.
## History Truncation And Cleanup
`routes/history/history_routes.py` verifies session ownership before parsing truncation requests. The body must be a JSON object with an explicit `keep_count` or nonblank string `before_msg_id` (`message_id` alias); null/empty primary IDs fall through to the alias, while other nonstrings fail. Counts accept nonnegative integers or integer strings, rejecting booleans, floats, null and negative values. A missing message boundary returns 404 before history mutation; explicit zero remains a supported clear operation. Invalid JSON/targets return 400 without deleting saved messages or SFT trace pairs. Cleanup uses durable message rows and rechecks the count at deletion; see `persistence.md`.
## Current Gaps
- Chat, agent, research, and compare orchestration still meet in a large route file.
- Context preface behavior is spread across `routes/chat_helpers.py`, `src/chat_processor.py`, route injections, and agent/tool paths.
- Detached stream lifecycle spans `routes/chat_routes.py`, `src/agent_runs.py`, `static/js/chat.js`, `static/js/sessions.js`, and non-chat callers.
- Some frontend stream state is still global/module-level in `static/js/chat.js` and needs careful session isolation when adding background or resumable flows.
- Route scope, stream errors, tool-policy preprocessing, request contracts, and runtime preparation have focused tests; full browser/provider/resume behavior still spans several modules.
- Scoped bearer admission and incognito message-history handling have focused regressions; they do not establish a universal no-write or live provider guarantee.
- Chat lacks route-level SSE regression tests for `/api/chat_stream`, live resume/stop/status, mode handoff, persistence metadata, partial-save behavior, attachment/doc-update events, browser timezone offset/workspace handling, and literal URL context intent.
- Bearer-token behavior on browser chat routes and incognito persistence need explicit contract decisions and regression coverage.
+5 -11
View File
@@ -1,6 +1,6 @@
# Compare
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -40,9 +40,11 @@ Frontend compare behavior is split by responsibility:
- `panes.js` and `stream.js` own paired response rendering;
- `vote.js` and `scoreboard.js` own voting and history display.
Compare panes can receive `ask_user` or tool-approval controls from the shared chat stream. `static/js/compare/stream.js` renders shared choice controls inside the originating pane history, pauses pane completion/autograding while a choice is pending, and resumes only while the pane session still matches. Continuation waits for the originating controller to release the pane, restores the card after a ten-second timeout, and refuses to send a stale choice into a replacement controller; compare orchestration keeps its busy state until those continuations settle.
Compare panes can receive `ask_user` or tool-approval controls from the shared chat stream. `static/js/compare/stream.js` routes those controls into the main chat renderer/control plane, pauses pane completion/autograding while a choice is pending, and can resume the pane after the user decision; compare orchestration keeps its busy state until those continuations settle.
Mobile compare uses selectable pane tabs and one visible active pane; desktop retains the selected multi-column grid. Pane add/remove/shuffle paths keep active-tab and session/settings indices aligned.
Mobile compare layout collapses multi-pane grids to a single column so panes
remain readable on narrow screens while the desktop grid still uses the
selected column count.
## Ownership Boundaries
@@ -67,14 +69,6 @@ Compare frontend code is part of the app DOM security surface. Current stream/se
- Shared legacy `ModelEndpoint.owner == NULL` rows remain visible through owner filters. Legacy `Comparison.owner == NULL` rows are not treated as shared for authenticated vote/delete/history flows.
- `/api/compare/start` and `/{comp_id}/vote` remain implemented but are not the active frontend path.
## Per-Pane Generation Controls
`static/js/compare/panes.js` owns per-pane inference controls and saves `thinking_mode`, `temperature_override`, and `max_tokens_override` to `/api/session/{id}/generation-settings`. The backend session/provider path owns the actual generation semantics. Pane creation initializes overrides, removal and shuffle preserve index alignment, and mandatory-thinking model controls are disabled where identified. `stream.js` renders response statistics including token/latency/context/cost metrics and the thinking-off indicator; compare header statistics open a compact details popup.
## Shared Celebration Renderer
`static/js/compare/vote.js` imports and reexports `spawnConfetti` from `static/js/shared/ui/confetti.js`, published from `frontend/src/shared/ui/confetti.js`. Notes imports the shared renderer directly; voting no longer owns its implementation. Vote behavior and callers retain the compatibility export.
## Current Gaps
- Blind mode is not a confidentiality boundary; client/local state can still expose model identity before vote.
+10 -25
View File
@@ -1,6 +1,6 @@
# Context Building
Last updated: dev@86b886c | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -9,10 +9,10 @@ This spec covers model-context construction in:
- `src/chat_processor.py`;
- `src/chat_handler.py` and `src/youtube_handler.py`;
- `routes/chat_helpers.py` and context injection in `routes/chat_routes.py`;
- `src/agent_loop.py`, `src/clean_agent_preview.py`, `src/turn_contract.py`, and `src/agent_runtime/context_resolution.py`;
- `src/media_ingress.py`, `src/agent_tools/media_tools.py`, and `src/email_attachment_text.py`;
- `src/agent_loop.py`;
- `src/tool_execution.py`;
- `src/attachment_refs.py` and uploaded-file manifest construction in `routes/chat_helpers.py`;
- `src/attachment_refs.py` and uploaded-file manifest construction in
`routes/chat_helpers.py`;
- `src/tool_policy.py`;
- `src/prompt_security.py`;
- `src/tool_capabilities.py`, `src/tool_approval_scopes.py`, and `src/tool_approvals.py`;
@@ -53,9 +53,12 @@ Current untrusted context sources include:
- emails and attachments;
- tool output from external/user-controlled data.
Live multimodal provider blocks can contain data URLs, but persisted and tool-facing context uses stable attachment references. Tool manifests carry an `odysseus://attachment/<id>` URI and owner-checked read policy; local paths are compatibility data added only after owner and root-confinement checks. Persisted chat context keeps readable text/reference lines rather than reinserting raw media bytes into later turns or search state.
`/api/chat_stream` accepts explicitly selected Notes/Todos through the JSON form field `selected_note_ids`. `routes.chat_routes._parse_selected_note_ids()` limits the encoded field to 8192 characters and the array to 50 nonempty string IDs of at most 128 characters, deduplicating valid IDs and returning `400` for malformed input. The route queries only rows whose `Note.owner` exactly matches `effective_user(request)`; missing or other-owner IDs contribute no context. Titles, content, note type, and checklist item state are formatted by `_format_selected_notes_context()` and passed to `routes.chat_helpers.build_chat_context()` as an untrusted "Selected notes and todos" preface. Selection supplies reference data, not tool authority; the preface discourages redundant retrieval or permission requests just to read those same supplied notes.
Live multimodal provider blocks can contain data URLs, but persisted and
tool-facing context uses stable attachment references. Tool manifests carry an
`odysseus://attachment/<id>` URI and owner-checked read policy; local paths are
compatibility data added only after owner and root-confinement checks. Persisted
chat context keeps readable text/reference lines rather than reinserting raw
media bytes into later turns or search state.
## URL, Search, And Tool-Derived Context
@@ -101,24 +104,6 @@ Guide-only/no-tools policy can suppress context acquisition before the model cal
- `src.tool_execution` for `web_search`, `web_fetch`, file, shell, MCP, and other tool outputs;
- `src.deep_research` and research handlers for search/fetch/extract flows used by research jobs, with fetched webpage text wrapped before extraction and analyzed URLs tracked separately from source snippets.
## Effective Context And Media Evidence
Compact preview preparation resolves the effective context window once through `src.agent_runtime.context_resolution`; terminal metrics use that stored resolution and do not trigger new discovery. Runtime-confirmed, provider-advertised, operator-declared, static-table, and unknown evidence remain distinct, with conflicts recorded. Operator declarations can cap stronger evidence; unknown is represented as zero rather than an invented known limit. Metadata probing has a three-second preparation deadline, caches remote observations, and re-probes local serving processes. Legacy chat/agent budgeting remains a separate compatibility path.
`src.context_compactor` trims context and prunes multimodal images. Session memory-injection and skill-injection toggles independently control preface construction; memory extraction is a separate session setting. `src.skill_index` semantically ranks native skills using an already-ready ToolIndex embedding lane, with lexical fallback, while `search_chats` retrieves owner-scoped historical messages instead of making all history ambient context.
`src.media_ingress` limits file counts, source/encoded bytes, image dimensions/pixels, document characters, video frames, and probe/extraction time. Native media tools return observed media/text evidence through the same untrusted-result path; filenames and model guesses do not establish inspected image/video content. `src.email_attachment_text` supplies shared attachment text extraction for mail context.
## Resolution And Embedding Limits
`src.agent_runtime.context_resolution.ContextResolution` binds effective context evidence to the resolved endpoint/model before compact preparation shapes history. The stored resolution feeds terminal metrics without another discovery call. A legacy shaping default does not turn unknown evidence into a known limit; a bare integer cannot supply typed provenance. Metadata requests use the turn's provider headers only at configured or server-resolved provider origins, without credential-following redirects, and endpoint URLs stay out of serialized resolution metrics. Remote observations cache for 600 seconds, failures for 60 seconds, while local servers are re-probed.
`src.embeddings` accepts an explicit `FASTEMBED_THREADS` integer from 1 through 256 and raises for malformed or out-of-range values rather than silently changing the requested execution setting. This controls embedding setup, not proof of a ready vector service.
## Compact Source Projection
Compact editor/email preparation can use current memory and bounded prior-source references without importing arbitrary hidden document bodies. The email classifier supplies dependency evidence for the selected path, not a general model-routing authority. Document edits are bounded batches with per-edit outcomes: a saved `doc_update` is distinct from an unapplied suggestion, exact unique-text matching can reject an ambiguous suggestion, and partial success does not mean every proposed edit was saved.
## Current Gaps
- URL/search context result shape is not unified across chat prefetch, agent tools, and research.
+3 -15
View File
@@ -1,6 +1,6 @@
# Cookbook And Hardware Fit
Last updated: dev@86b886c | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -40,7 +40,7 @@ Access policy is split by surface:
- download/setup/SSH key/cache scan/serve/GPU/kill/state/task-status are admin/internal-tool surfaces;
- `/api/cookbook/hf-latest` is authenticated-user gated;
- HW Fit routes carry a router-wide admin dependency because host/model-path probes can use server SSH/filesystem authority;
- HW Fit routes are authenticated read/probe routes through normal middleware, not admin-only operations;
- bearer API tokens do not satisfy Cookbook admin gates.
Runtime behavior:
@@ -130,7 +130,7 @@ Runtime behavior:
- Linux, Windows/PowerShell, macOS, Docker, NVIDIA, AMD, Apple Silicon, and CPU-only systems have different command paths.
- Remote hosts are accessed through SSH helpers; Cookbook host/port/path inputs must be validated before command construction.
- HW Fit detection targets use shared host/port validation before remote probing; the router-wide admin gate still owns admission.
- HW Fit remote host/port query values currently do not share all Cookbook route-level validation before SSH probing.
- Missing local tools or failed installs should surface command/output/error detail where possible.
- GPU overlays remain optional and do not break CPU-only deployments.
- Docker GPU overlays pass host devices/env; they do not install CUDA/ROCm engines by themselves.
@@ -188,18 +188,6 @@ Existing coverage is strongest for helper validation/quoting, SSH host validatio
Route-level auth/security and degraded-return coverage is thinner for Cookbook admin routes, shell dependency routes, `/api/cookbook/hf-latest`, state/status edge cases, HW Fit routes, frontend JS behavior, and helper scripts such as `hf_download.py`, `add_hwfit_models.py`, and `diffusion_server.py`.
## Native Control And Adapter Contracts
`routes/cookbook_routes.py` gates native download/serve producers even when scoped wrappers call them directly. Local tools use a one-use admitted operation/backend/body capability from `src.agent_runtime.local_model_control`; fallback administration uses the shell admin gate. Kill-PID, SSH-key and state mutation do not inherit that model capability. Remote operations retain separate validation and admin requirements.
Cached-model scans pass request-owned script bytes over subprocess stdin instead of sharing `scan_cache.py`; cancellation/timeout terminates that request's scanner. Image serving accepts a shell-safe explicit `runtime_adapter`, passes it as `ODYSSEUS_MLX_IMAGE_ADAPTER`, and retains `auto` detection with dependency preflights. HW Fit ranking carries image dependency-package metadata; native AWQ rows no longer require multiple GPUs on otherwise supported accelerator platforms.
## Native Process And Catalog Limits
Tool-reachable local Cookbook stop refuses legacy unadmitted tmux/process targets with resource-identity denial before scanning or killing; native model capabilities admit only the exact owner/request/backend/body and consume once. Scheduled native serving requires the sealed capability, while remote/operator HTTP flows retain their distinct validation. Procfs discovery returns empty when procfs is unavailable and cannot prove absence or authorize survivor reclamation. Shared process cleanup freezes known descendants, checks start tokens and observes death; uncertain identity is retained, with unavoidable signal/observation races rather than guaranteed portable teardown.
Image collection refresh has a short in-memory and longer disk cache; network failure can preserve stale model data rather than force an empty catalog. Latest lookup and organization prefilters use external metadata and heuristic first-party/format/parameter sizing; fit estimates (including a 90-percent VRAM budget) are recommendations, not measured serving acceptance. Bundled HF/MLX inventories remain empty in final source, and generated inventory inspection is separate from consumer/schema review.
## Current Gaps
- Cookbook-created model endpoint ownership/shared/null-owner policy needs a deliberate decision.
+47 -32
View File
@@ -1,6 +1,6 @@
# Documents, RAG, And Uploads
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -10,7 +10,8 @@ This spec covers file/document context, document storage, and vector retrieval i
- `routes/upload_routes.py`, `routes/personal_routes.py`, `routes/embedding_routes.py`, canonical `routes/document/document_routes.py` and `routes/document/document_helpers.py`, plus their top-level compatibility shims;
- chat attachment paths in `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, and `src/chat_processor.py`;
- `core/session_manager.py`, `src/attachment_refs.py`, `src/upload_handler.py`,
`src/upload_limits.py`, and the public reference contract in `website/attachments.md`;
`src/upload_limits.py`, and the public reference contract in
`docs/attachments.md`;
- `src/document_processor.py`, `src/document_actions.py`, `src/personal_docs.py`, and `src/markitdown_runtime.py`;
- `src/rag_singleton.py`, `src/rag_vector.py`, `src/rag_manager.py`, `src/chroma_client.py`, `src/embeddings.py`, and `src/embedding_lanes.py`;
- PDF/form helpers in `src/pdf_runtime.py`, `src/pdf_forms.py`, and `src/pdf_form_doc.py`;
@@ -53,17 +54,45 @@ Chat does not own attachment extraction. Runtime flow:
- `src.document_processor.build_user_content()` produces model-ready text, PDF text, Office/EPUB text when MarkItDown or the DOCX fallback is available, image/multimodal blocks, truncation, and PDF/Office auto-document updates;
- chat streams attachment, PDF-created `doc_update`, and `rag_sources` events where applicable.
Extensionless image and audio attachments derive their data-URI subtype from the detected MIME type, so `image/png` and `audio/mpeg` uploads do not become invalid `data:image/;base64` or `data:audio/;base64` blocks when the filename has no extension.
Extensionless image and audio attachments derive their data-URI subtype from
the detected MIME type, so `image/png` and `audio/mpeg` uploads do not become
invalid `data:image/;base64` or `data:audio/;base64` blocks when the filename
has no extension.
## Durable References And Cleanup
`src.attachment_refs` owns the stable `attachment_ref` shape used outside raw upload storage: attachment id, name, MIME type, size, and optional checksum, creation time, dimensions, vision text/model, and gallery id. Live provider calls may still receive multimodal data URLs for the current turn, but durable chat content is normalized to readable text plus compact reference lines. Structured references remain in message attachment metadata, and chat FTS triggers omit inline media while startup migration scrubs legacy indexed data URLs.
`src.attachment_refs` owns the stable `attachment_ref` shape used outside raw
upload storage: attachment id, name, MIME type, size, and optional checksum,
creation time, dimensions, vision text/model, and gallery id. Live provider
calls may still receive multimodal data URLs for the current turn, but durable
chat content is normalized to readable text plus compact reference lines.
Structured references remain in message attachment metadata, and chat FTS
triggers omit inline media while startup migration scrubs legacy indexed data
URLs.
Agent/tool manifests expose `odysseus://attachment/<id>` with `read_policy: "owner_checked_upload"`. A compatibility filesystem path is included only after owner-aware upload resolution, upload-root confinement, and tool-readable-root checks; the stable contract for external tools is the URI and attachment id, not host layout.
Agent/tool manifests expose `odysseus://attachment/<id>` with
`read_policy: "owner_checked_upload"`. A compatibility filesystem path is
included only after owner-aware upload resolution, upload-root confinement, and
tool-readable-root checks; the stable contract for external tools is the URI
and attachment id, not host layout.
Writers reserve referenced uploads before committing durable state. This includes session message append/replace and history rewrites, document create/update and native document edits, note route/tool create/update, calendar/event route/tool create/update, and attachment-bearing session updates. A missing or wrong-owner reference aborts before destructive replacement and surfaces a route conflict or tool error. Reservations serialize with cleanup through the upload-index lock and refresh access time.
Writers reserve referenced uploads before committing durable state. This
includes session message append/replace and history rewrites, document
create/update and native document edits, note route/tool create/update,
calendar/event route/tool create/update, and attachment-bearing session
updates. A missing or wrong-owner reference aborts before destructive
replacement and surfaces a route conflict or tool error. Reservations serialize
with cleanup through the upload-index lock and refresh access time.
Admin cleanup first scans chat content and attachment metadata, current and versioned documents including PDF markers, gallery filenames/hashes, note image/color/content/checklist fields, and calendar color/description/location fields. Reference discovery or index-integrity failure aborts cleanup; the lower-level API removes nothing without both completed id and hash snapshots. Only expired, unreferenced files with coherent id/path/owner/checksum/timestamp metadata are candidates. Matching index rows are persisted away before byte deletion and restored if deletion fails. This lock is process-local, so the documented race protection assumes the current single-worker deployment.
Admin cleanup first scans chat content and attachment metadata, current and
versioned documents including PDF markers, gallery filenames/hashes, note
image/color/content/checklist fields, and calendar color/description/location
fields. Reference discovery or index-integrity failure aborts cleanup; the
lower-level API removes nothing without both completed id and hash snapshots.
Only expired, unreferenced files with coherent id/path/owner/checksum/timestamp
metadata are candidates. Matching index rows are persisted away before byte
deletion and restored if deletion fails. This lock is process-local, so the
documented race protection assumes the current single-worker deployment.
## Living Documents And PDF
@@ -71,13 +100,19 @@ Admin cleanup first scans chat content and attachment metadata, current and vers
`static/js/documentLibrary.js` owns local library state after archive/delete actions, including total counts and language chips. Server route truth still owns durable document state.
`static/js/document.js` owns the browser document editor, a sanitized rich-text editing view and Markdown source/preview. Preview rendering applies code highlighting when highlight.js is present, renders Mermaid diagrams when the Mermaid runtime is available, refreshes after AI edits, and discards pending AI diffs before switching the active document.
`static/js/document.js` owns the browser document editor and markdown preview. Preview rendering applies code highlighting when highlight.js is present, renders Mermaid diagrams when the Mermaid runtime is available, refreshes after AI edits, and discards pending AI diffs before switching the active document.
Document mutations also happen through agent tools, Codex document routes, email attachment import, and scripts. HTTP and native-agent document writers owner-reserve any internal upload/PDF references before persisting new current content or versions. Native document tool outputs include metadata that the browser can use to open/update the editor if a later stream update is missed. Those callers must preserve document owner, attachment, and version semantics.
After external/workspace-untrusted context, a proposed document mutation is sealed into an exact approval with document id, current version, content digest, tool content, owner/session, and workspace. Approval continuation re-reads and verifies those fields before consuming the one-use authorization, so an intervening edit cannot apply a stale approved patch to new content.
Email draft documents are a first-class document language. Create/update paths detect the `To`/`Subject`/header shape, coerce language to `email`, and preserve protected reply/forward headers such as `In-Reply-To`, `References`, `X-Source-UID`, `X-Source-Folder`, attachment headers, and quoted/original history when model or UI edits replace the draft body. Creating a draft for the same source UID/folder in the same session updates the active draft instead of creating a duplicate.
Email draft documents are a first-class document language. Create/update paths
detect the `To`/`Subject`/header shape, coerce language to `email`, and preserve
protected reply/forward headers such as `In-Reply-To`, `References`,
`X-Source-UID`, `X-Source-Folder`, attachment headers, and quoted/original
history when model or UI edits replace the draft body. Creating a draft for the
same source UID/folder in the same session updates the active draft instead of
creating a duplicate.
`Document` rows own current content and owner. `DocumentVersion` rows own immutable snapshots. Document access should be owner-filtered, not session-id-only; the session document listing path still needs regression coverage for per-document owner filtering after the session owner check.
@@ -128,7 +163,7 @@ Some non-route tool/script paths can index ownerless or arbitrary directories an
## Optional And Degraded Behavior
- ChromaDB/FastEmbed are default installed dependencies, but Chroma can be offline or unreachable.
- Native Chroma defaults to `localhost:8100`; Docker uses the `chromadb:8000` compose service with `chromadb-data:/data` in all three Compose variants. Pre-fix containers can hold live vectors in their writable layer; preserve/rescue that data before recreation using `docs/chromadb-volume-migration.md`.
- Native Chroma defaults to `localhost:8100`; Docker uses the `chromadb:8000` compose service and persistent Chroma storage.
- HTTP embeddings can fall back to FastEmbed; when both lanes exist, lane separation avoids Chroma dimension conflicts.
- MarkItDown is optional for Office/EPUB extraction; chat attachments and personal directory indexing have clear degraded behavior, while direct RAG upload does not share the same extraction path.
- PyMuPDF is optional, unlocks PDF form/render/fill paths, and carries AGPL implications when installed.
@@ -151,7 +186,7 @@ Concrete enforcement points include:
Extracted attachment text is currently appended into the user message rather than wrapped as a separate untrusted-context message. That is current behavior and a prompt-injection hardening gap.
Browser document routes still need explicit bearer-token scope/effective-user policy. Upload endpoints have their own delegated owner/scope gates described below; that contract does not extend to every document/PDF/RAG route.
Bearer-token callers are not a scoped document/upload API surface today. Routes that treat token-authenticated users as owners need explicit scope/effective-user policy before they are considered safe token APIs.
## Testing Coverage
@@ -159,26 +194,6 @@ Existing useful coverage includes upload owner scope, upload IDs, upload atomici
Route-level coverage is thinner for document CRUD, PDF import/render/export/fill, direct RAG upload, embedding admin/security behavior, and RAG unavailable states.
## Preserved Originals And Document Views
`POST /api/documents/import-docx` requires document privilege, verifies any supplied session owner, stores the original through `UploadHandler`, extracts Markdown, and creates a `docx` document with a `docx_source` upload marker. `GET /api/document/{doc_id}/render-docx` checks document and source-upload visibility, uses Mammoth to return an HTML fragment for client sanitization, and returns 503 when Mammoth is absent. The frontend supplies a paper preview while extracted text remains available for editing/search/model context.
`GET /api/document/{doc_id}/convert-original/{target}` converts preserved DOCX to PDF or PDF to DOCX directly with host LibreOffice/soffice, after document privilege, ownership, and source-upload checks. It uses a temporary output directory and isolated LibreOffice profile in a worker thread with a 120-second subprocess timeout; it does not convert from extracted Markdown. Missing LibreOffice returns 503, timeout 504, and unsuccessful output 502.
Markdown documents can open `GET /api/document/{doc_id}/visual-report`, reusing `src.visual_report.generate_visual_report()` after document ownership validation. ZIP export recognizes email drafts as `.eml` and converts their header/body separator into an RFC-style blank line. The frontend document graph now also includes `documentOutline.js` and `documentStats.js`.
## Editing And Export Boundaries
Rich-text editing mirrors a textarea and supports formatting, checklists, images and tables through sanitized contenteditable content. Internal markers are removed from export; converting rich text/Markdown into simple DOCX paragraphs is lossy and does not preserve arbitrary Word layout. Browser PDF export uses a script-disabled print iframe with same-origin/modal allowances, waits for fonts/styles/images/math and cleans up afterward; server PDF rendering and preserved-original LibreOffice conversion are separate paths.
Per-document save queues serialize requests and track edit revisions so intervening edits remain marked dirty; this does not promise that every cached response body preserves a newer in-memory edit. Restoring the editor targets the exact active document. Suggestions apply only when the exact find text is unique, report partial applied/unapplied suggestion IDs and retain unapplied review state; server-saved edits suppress duplicate frontend application. Protected email envelope/history remains distinct from editable draft content. PDF/signed-reply preparation still has late async ownership/account edge cases requiring targeted checks.
## Upload Authentication And Delegated Scopes
`routes/upload_routes.py` explicitly bypasses upload ownership/auth-service gates only when auth is disabled, including after accounts were configured. With auth enabled, missing auth manager returns 503, an unconfigured manager returns 401, and missing/request-sentinel identity returns 403. Reads, thumbnails and vision reads/edits use `_verify_upload_access()` before confined path access. Interactive owners may access their uploads; verified interactive admins may access other owners and legacy ownerless uploads. Delegated tokens never inherit that administrator override and require an actual token owner matching a non-null file owner.
Delegated creation and OCR edits require any of `chat` or `documents:write`; downloads/thumbnails/OCR reads accept any of `chat`, `documents:read`, `documents:write`. Missing/unrelated scopes and invalid token owners fail with 403; owner mismatch/ownerless token access uses 404. POST saves effective owner attribution and clears request-sentinel owners. No-login anonymous uploads can remain null-owned and become interactive-admin-only after auth is enabled. Size/type/filename/path validation remains active in no-login mode; these gates do not replace the global authentication middleware.
## Current Gaps
- Direct RAG upload still needs clearer file-type validation and MarkItDown/PDF extraction parity decisions.
@@ -186,5 +201,5 @@ Delegated creation and OCR edits require any of `chat` or `documents:write`; dow
- Chat RAG can remain degraded after startup even if personal routes later initialize the RAG singleton.
- PyMuPDF-dependent routes do not all share the same optional-runtime helper/error behavior.
- Signed-reply preparation needs owner-scoped source email account/signature regression coverage.
- Browser document/PDF/RAG routes need explicit bearer-token scope/effective-user policy beyond the upload endpoint contract above.
- Document/upload routes need explicit bearer-token scope/effective-user policy.
- User-facing document/PDF/RAG route matrices need more regression coverage for owner denial, admin gates, unavailable services, and degraded optional dependencies.
+18 -35
View File
@@ -1,6 +1,6 @@
# Email And Contacts
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
@@ -39,7 +39,7 @@ This spec covers mail and contacts in:
Exactly one default account per owner is enforced as a serialized database transition. Startup normalizes legacy duplicate defaults and installs a unique per-owner default constraint/index; first create, delete/promotion, set-default, demo teardown, and owner rename lock the relevant owner rows and commit atomically. Multi-owner rename acquires locks in canonical order so stale concurrent writers fail closed.
`routes.email.email_helpers` owns (flat imports remain compatibility aliases):
`routes.email_helpers` owns:
- account owner assertions and config fallback order;
- IMAP/SMTP connection helpers and related transport utilities;
@@ -57,9 +57,11 @@ Email owner semantics are route-local and compatibility-sensitive:
- `routes.email_helpers._require_auth()` returns `""` in `AUTH_ENABLED=false` mode, rejects configured auth with no user, and only tolerates first-run anonymous loopback fallback.
- Empty owner is treated as single-user compatibility: account-ownership assertions no-op, default/first-enabled account fallback can be global, and email cache clauses include `owner = '' OR owner IS NULL`.
- Non-empty owners scope account/config/cache queries. Legacy ownerless account
rows are visible to an authenticated owner only when the row's IMAP username or from-address matches that owner, so old unowned rows do not become global cross-user accounts in configured multi-user deployments.
rows are visible to an authenticated owner only when the row's IMAP username
or from-address matches that owner, so old unowned rows do not become global
cross-user accounts in configured multi-user deployments.
`routes.email.email_routes` owns the HTTP mail surface:
`routes.email_routes` owns the HTTP mail surface:
- account CRUD, test, default, and masked config reads;
- Google OAuth authorize/callback for Workspace and .edu Gmail-style accounts;
@@ -89,7 +91,7 @@ IMAP helpers quote mailbox names, raise the Python IMAP line cap for large messa
Scheduled email rows live in `data/scheduled_emails.db` and are owner-scoped. Scheduled send times are normalized before storage.
`routes.email.email_pollers` owns the scheduled-send poller and single-shot/task/CLI automation passes. Before SMTP work, each poller atomically claims a due row with a conditional `pending` to `sending` update; concurrent in-process/CLI pollers that lose the claim skip the row instead of sending a duplicate. Only the scheduled-send poller starts in-process by default when `ODYSSEUS_INPROCESS_POLLERS` allows it; Docker forwards that gate. Background email automation can also consult the foreground activity gate so auto actions do not compete with active browser/model work. Native cron/systemd can drive one-shot pollers through `scripts/odysseus-mail`.
`routes.email_pollers` owns the scheduled-send poller and single-shot/task/CLI automation passes. Before SMTP work, each poller atomically claims a due row with a conditional `pending` to `sending` update; concurrent in-process/CLI pollers that lose the claim skip the row instead of sending a duplicate. Only the scheduled-send poller starts in-process by default when `ODYSSEUS_INPROCESS_POLLERS` allows it; Docker forwards that gate. Background email automation can also consult the foreground activity gate so auto actions do not compete with active browser/model work. Native cron/systemd can drive one-shot pollers through `scripts/odysseus-mail`.
Manual and scheduled summaries use the shared LLM adapter and owner-scoped cache instead of constructing provider calls locally. Scheduled summaries use background fallback policy and yield to foreground work; provider exception text is shaped before it can reach the browser.
@@ -105,9 +107,9 @@ Transport degraded behavior:
## Caching And Staleness
Email list/read behavior uses short route caches, longer read caches, capped warm prefetch, and owner/account-aware pool/cache keys. The frontend email library has bounded in-memory and local caches, cache-buster refreshes, scheduled/search cache exclusions and stale rows on refresh failure. A cached early return is not a universal stale-while-revalidate guarantee.
Email list/read behavior uses short route caches, longer read caches, capped warm prefetch, and owner/account-aware pool/cache keys. The frontend email library has its own session SWR cache, cache-buster refreshes, scheduled/search cache exclusions, and stale-row behavior when refresh fails.
Opening an unread message is one authoritative backend IMAP operation. The read route fetches/parses the message and applies `\Seen` over the same connection; cached bodies still await one UID STORE, read-only mailboxes serve content without claiming a mark, and STORE failure returns the body with explicit failure state rather than caching a false read. Inbox/library clients deduplicate opens and carry mailbox context with selected request-generation guards. Those guards are not universal: some detached/inline completions still use mutable account/folder state, so late-response safety must be assessed per path.
Opening an unread message is one authoritative backend IMAP operation. The read route fetches/parses the message and applies `\Seen` over the same connection; cached bodies still await one UID STORE, read-only mailboxes serve content without claiming a mark, and STORE failure returns the body with explicit failure state rather than caching a false read. Inbox/library clients deduplicate opens, carry immutable mailbox context, and ignore late responses after account, folder, or message changes.
Library prewarm runs only while genuinely idle, as one bounded single-flight request for the default or last-used enabled account and initial page. Visible foreground work, panel lifecycle, account changes, or explicit reads cancel or join it so delayed duplicate IMAP work cannot escape the idle gate.
@@ -115,7 +117,7 @@ List/read route caches are owner/account-aware. Helper-side summary, AI-reply, t
## Attachments And Signed Replies
Compose uploads live under `ODYSSEUS_MAIL_ATTACHMENTS_DIR`; missing staged files are skipped with warnings. Attachment-to-document supports PDF, DOCX, TXT, and MD. DOCX attachment-to-document extraction uses MarkItDown first; optional LibreOffice conversion has a 60-second bound and can supply a PDF preview, otherwise extracted Markdown remains available; PDF form/open-in-doc flows can depend on optional PyMuPDF.
Compose uploads live under `ODYSSEUS_MAIL_ATTACHMENTS_DIR`; missing staged files are skipped with warnings. Attachment-to-document supports PDF, DOCX, TXT, and MD. DOCX depends on `python-docx`; PDF form/open-in-doc flows can depend on optional PyMuPDF.
Email attachment-as-document flows stamp `Document.source_email_*` provenance. `GET /api/email/attachments-download/{uid}` builds an owner-scoped ZIP of visible non-signature attachments using safe names. `compose-from-odysseus` and `compose-from-odysseus-zip` can stage owner-visible documents and gallery images as compose uploads, preserving legacy session fallback only where the source object remains visible to the owner. `prepare-signed-reply` verifies document ownership, reconstructs reply headers, flattens/stages signed PDFs as compose uploads, and leaves final send/draft review to the compose flow.
@@ -133,7 +135,12 @@ When the email reader is active, browser chat sends selected-message metadata. `
`mcp_servers/email_server.py` exposes email tools for MCP/agent use. It has its own account discovery, IMAP/SMTP, attachment, cache, and send paths, but account visibility now mirrors the HTTP owner policy. The active owner comes from a hidden `_odysseus_owner` argument when the caller provides one, or from `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`. If any enabled account is owner-scoped and no current/configured owner exists, email MCP returns an owner-scope error instead of listing global accounts.
MCP email account filtering includes owner-owned rows and legacy ownerless rows whose mailbox/from-address matches the owner. Confirmation-first `send_email` resolves the selected account before stashing an `agent_draft`, so drafts cannot be staged against another owner's account. MCP-created draft documents use the resolved hidden/configured owner when available, with `ODYSSEUS_DOCUMENT_OWNER` and single-admin fallback only as document-visibility compatibility.
MCP email account filtering includes owner-owned rows and legacy ownerless rows
whose mailbox/from-address matches the owner. Confirmation-first `send_email`
resolves the selected account before stashing an `agent_draft`, so drafts cannot
be staged against another owner's account. MCP-created draft documents use the
resolved hidden/configured owner when available, with `ODYSSEUS_DOCUMENT_OWNER`
and single-admin fallback only as document-visibility compatibility.
MCP email send behavior is confirmation-first by default: `send_email` and reply send paths stash a `scheduled_emails` row with `status='agent_draft'` when `agent_email_confirm` is true, and browser routes expose pending drafts for approval or cancellation. Separate MCP draft tools create Odysseus compose documents for user review without sending.
@@ -154,7 +161,7 @@ Contact runtime behavior:
- the native contacts CLI is CardDAV-oriented and does not fully match web JSON fallback behavior;
- agent contact tools reuse helper functions in-process because the HTTP routes require browser/admin auth.
HTTP contact administration remains admin-gated, but in-process helpers have owner-aware local rows and per-owner caches. Named owners see their own rows plus legacy ownerless contacts; synthetic `sft_` owners do not see ownerless rows and bypass CardDAV. This compatibility does not establish a general cross-user sharing API.
Contacts are global admin-only data today. There is no per-user contact sharing model unless a future spec defines one.
## Security Policy
@@ -170,7 +177,7 @@ Known security policy details:
- Google OAuth state and callback owner checks are part of the account-boundary contract;
- scheduled emails must remain owner-scoped;
- email pre-retrieval contacts context is allowed only for admin/single-user situations;
- HTTP and MCP attachment extraction share `src.mail_attachment_paths.attachment_scope_dir()` for hashed owner/account/folder/message directories; remaining attachment consumers still need end-to-end scope/path coverage.
- MCP attachment downloads need route-level path-containment parity; current MCP paths are separate from the HTTP compose/attachment helper path.
CardDAV credentials and URLs are security-sensitive. CardDAV URL setup and derived href writes/deletes pass through outbound URL validation; absolute hrefs from a CardDAV server are constrained back to the configured origin before credentials are reused. CardDAV passwords in settings are encrypted and masked on read; environment-sourced legacy password values are used as supplied.
@@ -189,30 +196,6 @@ Existing coverage includes header/envelope/IMAP/SMTP behavior, serialized defaul
Route-level and duplicate-path coverage is still thin for email list/read/search/mutations, account CRUD/security outside the OAuth path, send/draft security, attachments, scheduled-poller failures, contacts admin/CardDAV routes, MCP account/scope behavior, CardDAV degraded mode, and executable frontend behavior.
## Mail Safety And New Library Flows
Mail connection helpers resolve host addresses once and connect only to policy-approved addresses through `src.url_safety.connect_outbound_tcp()`, retaining hostname TLS verification. Link-local, non-loopback reserved, multicast and unspecified destinations are denied. IPv6 loopback and IPv4-mapped loopback follow the private-address policy rather than unconditional reserved-address denial. Exactly `64:ff9b::/96` is decoded to its IPv4 target and evaluated strictly, including all DNS answers; network-specific NAT64 prefixes are not inferred. Private/loopback/shared destinations are denied for ordinary multi-user owners, allowed for admins/auth-disabled operators, and configurable with `EMAIL_ALLOW_PRIVATE_IPS=true`; `EMAIL_BLOCK_PRIVATE_IPS=true` takes precedence and denies them for everyone.
The library adds owner-scoped unread-state, attachment metadata caches, bulk attachment ZIP, flagged-message actions, translation, and reviewed unsubscribe flows. `/unsubscribe/scan` reads newsletter/spam candidates; `/unsubscribe/execute` refetches the chosen message and supports mailto unsubscribe by sending through the selected account. HTTP unsubscribe links remain a manual-open path. `/unsubscribe/cleanup` moves reviewed candidates to Junk/Trash and can expand a sender's unsubscribe-header messages. Scanning alone does not send or move mail.
`src.mail_attachment_paths` hashes the full owner/account/folder/UID scope into an extraction directory so legacy ownerless paths cannot reuse another mailbox's staged attachment. `src.email_attachment_text` centralizes readable attachment extraction. Email calendar pollers apply ICS revisions through `src.email_calendar_import`; invitation state and reminder semantics are described in `calendar-tasks-notes.md`. `src.email_task_intent` supplies email-task intent helpers, and `src.email_reply_stream` handles reply-stream text behavior.
## Mailbox State And Delivery Boundaries
MCP email binds owner and active session in request-local contexts and resets them on exit. Cross-account list caching is short-lived (20 seconds) and listing can use four concurrent workers; it does not promise single-flight freshness. Nonempty local header-index results can suppress a live lookup. MCP sent-time ISO ranges normalize missing zones to UTC and use an inclusive start/exclusive end; browser date-only received-time filters include the end date. Cached/fixture paths do not uniformly apply every date filter. Search pills combine terms; index fallback and at-most-six attachment search terms do not guarantee complete provider search.
MCP bulk UID move fallback checks command statuses and verifies absence from the source; this is not proof of destination delivery. Browser and MCP single-message move paths instead rely on MOVE or COPY/STORE/expunge acknowledgements without a post-move source read.
Browser bulk delete requires a successful native MOVE or recoverable Trash COPY before removing originals. COPY failure retains source/index; fallback STORE batches only copied UIDs for selective RFC 4315 UID EXPUNGE, never mailbox-wide EXPUNGE. Index rows are removed only after confirmed deletion. Failed selective expunge retains the index and attempts to remove newly added `\Deleted` flags while preserving observed pre-existing flags; disconnects can prevent rollback, and a lost acknowledgement is not proof the source survived. Servers without selective expunge can therefore report failure despite a Trash copy. Responses distinguish confirmed deleted and failed UIDs, total failure, and partial success; source and Trash list caches invalidate in `finally` when any deletion was confirmed. Single-message paths retain their separate behavior.
Draft replacement appends the new mailbox draft before removing the old one. Sent draft cleanup follows successful Sent append; SMTP, IMAP and local database state are not one transaction. Scheduled send claims prevent concurrent claim winners, not exactly-once SMTP across a crash after send and before checkpoint.
Composer sends first save a recovery mailbox draft, then use `wait_for_delivery` and preserve exact draft UID/folder for delivery cleanup. Failures/cancellation retain recoverable draft state. AI reply application checks generation/document/body snapshots and intervening manual input before overwriting. Confirmation-first MCP pending drafts are a distinct workflow. Reply-by-Message-ID resolves owner/account/UID context rather than trusting a global id; recursive fallback work is bounded and gathered on cancellation. Reply generation disables thinking, uses bounded initial output and one larger retry; this does not guarantee provider delivery.
Unsubscribe scanning inspects at most the newest 500 candidates and deduplicates senders; bulk operations are bounded and can partially fail. A sent mailto attempt can move its source to Trash, while pending confirmation leaves it untouched; successful send-attempt bookkeeping is not confirmation that a remote subscription ended. Blocked-sender JSON is owner-keyed but not transactionally coupled to mailbox changes. Bulk attachment ZIP is capped at 250 messages in batches of twenty, skips failed messages, uses safe names and assembles in memory.
Reader HTML snapshots use inert parsing/sanitization and sandbox/CSP constraints; blocked network in browser tests is a test condition rather than universal runtime network isolation. Attachment-only/media-only bodies remain meaningful; a thumbnail `img.src` is not itself a URL sanitizer. Local reminder metadata can identify folder/UID without an account and is a narrower compatibility path than canonical mailbox identity. Contact cache success/failure windows and shared refresh locking do not turn a CardDAV outage into local-write success.
## Current Gaps
- Owner-keyed cache policy still needs an explicit decision for thread boundaries, plus continued migration/query audits for every email side table.
+16 -38
View File
@@ -1,6 +1,6 @@
# Frontend
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -11,14 +11,14 @@ This spec covers the current browser app in:
- `static/index.html`;
- `static/login.html`;
- `static/app.js`;
- ordered stylesheets under `static/css/`;
- `static/style.css`;
- `static/js/*.js` and `static/js/*/*.js`;
- vendor libraries under `static/lib/*`;
- custom fonts and static assets under `static/fonts/*`;
- `static/sw.js` and `static/manifest.json`;
- frontend-oriented tests in `tests/*_js.py`, `tests/*.mjs`, `tests/bombadil-spec.ts`, static DOM/CSS/source-shape tests, and app/static tests such as `tests/test_app_static_mime.py`.
`/backgrounds` remains registered in `app.py` but targets the absent `static/backgrounds.html`. Authentication middleware also gates it when auth is enabled, despite its handler docstring describing a public prototype page. This is a known route/static gap; the page is not a working frontend surface at the checked revision.
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
`static/manifest.json`, `static/index.html`, and `static/login.html` reference first-party PWA icon files under `static/icons/` (192px, 512px, and maskable). At runtime, route-specific manifests can also be generated as Blob URLs when supported, and per-route favicon links use generated inline SVG.
@@ -35,7 +35,7 @@ This spec covers the current browser app in:
## Runtime Shape
The browser runtime is a static native-ES-module SPA served by FastAPI, without a runtime Node/build dependency. Incremental maintained sources now live under `frontend/src/`, with explicit source-to-deployment mappings in `frontend/modules.json`; the map publishes `shared/actionMenuOrder.js`, the Notes feature graph and shared confetti to committed `static/js/` outputs. `scripts/build_frontend.py` publishes/checks those bytes during development, never application startup. Legacy modules remain authored under `static/`; there is no Vite/React/TypeScript bundle.
The frontend is a raw static SPA served by FastAPI. There is no Vite, React, TypeScript, bundler, or generated build output.
`app.py` owns:
@@ -49,11 +49,7 @@ The browser runtime is a static native-ES-module SPA served by FastAPI, without
The two first-paint Fira Code faces are preloaded so the shell does not wait for later CSS discovery. `static/js/startupShell.js` lets the visible shell initialize before session loading completes; session/transcript hydration is deferred and coordinated by `static/js/sessions.js` plus history/session routes rather than blocking first paint.
Exact script URL identity matters. Versioned script tags, unversioned imports, and service-worker precache entries must stay aligned. `static/sw.js` deliberately separates first-paint `PRECACHE` from lazy `PANEL_PRECACHE`; the latter contains the image-editor module graph so an editor never opened online can still open offline. The Notes feature graph and shared confetti are explicitly in `PRECACHE`, together with their exact legacy helper URLs. KaTeX scripts/styles/fonts are also precached. Current service-worker coverage is not a generated full module-graph manifest, so changes still need direct verification.
`static/style.css` has been removed. `static/index.html` loads numbered `static/css/00-tokens.css` through `17-research.css` in order, followed by the three domain panel stylesheets. `static/sw.js` precaches those exact versioned URLs; cascade order remains part of behavior.
New extracted owners include `static/js/turnRendering.js` for live/canonical turn DOM reconciliation, `backgroundToolJobs.js` for deduplicated background deliveries and research status cards, `emailReplyStream.js` for email reply streams, and `chatgptSubscriptionUsage.js` for account usage UI. Settings panel modules now also include `agent.js`, `api.js`, `imageModels.js`, `speech.js`, and `writingStyle.js`.
Exact script URL identity matters. Versioned script tags, unversioned imports, and service-worker precache entries must stay aligned. `static/sw.js` deliberately separates first-paint `PRECACHE` from lazy `PANEL_PRECACHE`; the latter currently contains the image-editor module graph so an editor never opened online can still open offline. KaTeX scripts/styles/fonts are also precached. Current service-worker coverage is not a generated full module-graph manifest, so changes still need direct verification.
## Security Policy
@@ -80,7 +76,7 @@ Storage/secrets policy:
`static/sw.js` owns PWA cache behavior:
- API and non-GET requests are bypassed;
- only `/` navigation uses network-first with cached-shell fallback; other navigation URLs are not replaced with the app shell;
- root navigation uses stale-while-revalidate;
- JS/CSS use network-first behavior;
- other static assets use cache-first with background refresh;
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
@@ -101,7 +97,7 @@ Current major frontend areas include:
- image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`;
- gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers.
`static/js/emailLibrary.js` is a compatibility wrapper over one unversioned package entry and explicit leaves for reader, menus, attachments, reply, settings and unsubscribe behavior; the final wrapper has ten named exports. `tests/test_email_library_module_graph_js.py` compares the unversioned public surface, imports immediate package modules in synthetic browser shells and checks quoted service-worker URLs. It does not prove every callback, nested module, load order or offline behavior. `tests/helpers/js_modules.py` uses immediate entry-first file enumeration and column-zero source extraction; its package-name binding regex is not lexical-scope analysis.
`static/js/emailLibrary/` is the one JS package with module-graph coverage: `tests/test_email_library_module_graph_js.py` pins the wrapper's public surface against the entry module's exports, evaluates every module on its own in a browser so an import cycle cannot hide a temporal-dead-zone read, and requires each module to be in the `sw.js` precache. Assertions about its source go through `tests/helpers/js_modules.py`, which reads the whole package, for the same reason `tests/helpers/stylesheets.py` reads the whole cascade.
Coordinator ownership:
@@ -120,15 +116,9 @@ Recent browser behavior contracts include mobile chat Enter inserting newlines w
The Settings finder and navigation are registry-backed, hide admin-only destinations from non-admin users, lazy-load admin panels, and keep the registry synchronized with DOM panels. Email OAuth connect preserves SMTP security and reopens the settings surface; unread message opens use one authoritative backend read/mark-seen request with stale-response guards; email-library prewarm is idle-only, single-flight, bounded to the initial page, and cancelled around visible foreground work.
## Notes Feature Ownership
`frontend/src/features/notes/index.js` composes one canonical Notes instance from domain rules/recurrence, application operations/state, infrastructure ports and UI factories. `frontend/modules.json` publishes its source modules byte-for-byte under `static/js/features/notes/` and shared confetti under `static/js/shared/ui/`. `static/js/notes.js` is the compatibility facade: it reexports the canonical methods/default, assigns `window.notesModule` and starts idempotent background reminders. Versioned/unversioned facade imports converge on the unversioned canonical entry. `static/app.js` retains the versioned facade caller; Compare voting imports and reexports shared confetti rather than owning it.
State mutations belong to `application/state.js`; UI gets live read-only views, and operation writes unwrap nested views to keep stored note data cloneable. The composition injects HTTP, persistence, clock/scheduler, notification and shared platform callbacks. Panel disposal releases owned portals, document/window listeners, gesture state and masonry observers; detached/closed-panel checks prevent late completions from recreating observers. Tablet reorder rewiring replaces stale binding sentinels. Reminder scheduling is page-owned and survives close/minimize; custom reminder date initialization uses DOM value assignment rather than HTML interpolation. Domain-specific drafts/reminder/selection contracts and remaining races are in `calendar-tasks-notes.md`.
## UI Policy
- New runtime code remains native browser ES modules; manifest-selected maintained sources use the development publication/check step without adding a startup build.
- New code must run as browser ES modules without a build step.
- Reuse existing CSS variables, modal/window patterns, icon style, storage helpers, and route conventions.
- Custom font handling includes bundled OpenDyslexic assets plus user-supplied fonts exposed through `/api/fonts/custom`; font and text-size settings must stay coordinated between settings UI, theme helpers, and CSS variables.
- Avoid relying on stale module summaries.
@@ -148,37 +138,25 @@ State mutations belong to `application/state.js`; UI gets live read-only views,
Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution.
`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a stylesheet cascade restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover.
`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a `static/style.css` restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover.
Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling.
Areas that still need explicit behavior verification include:
Missing coverage includes:
- SPA route/static auth and no-cache headers;
- CSP header contents and nonce injection for `/` and `/login`;
- service-worker API/non-GET bypass and cache strategy;
- complete service-worker precache membership versus HTML scripts/modules: stylesheet parity is covered by `tests/test_static_stylesheet_manifest.py`, and `tests/test_frontend_module_version_parity.py` checks selected app/index/SW versions plus regex-discovered JS/index URL identities, but neither proves complete graph evaluation;
- ongoing manifest/icon reference drift; `sw.js` now explicitly precaches the default manifest and three PWA icons;
- complete legacy module graph/load-order behavior beyond maintained-source architecture gates and the email-library browser graph;
- service-worker precache versus `index.html` script/module tags, including query strings (stylesheet links and their `?v=` strings are covered by `tests/test_static_stylesheet_manifest.py`; script and module tags are not);
- ongoing manifest/icon reference drift;
- module graph/load-order validation;
- degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path.
## Rendering, Provider UI And Static Checks
`turnRendering.js` reconciles canonical/live ownership with turn replacement metadata while keeping tool activity. Compact corrected/final events can replace earlier prose; the server completion gate can buffer answer deltas, so this is not a promise of immediate streaming. Ordinary ask-user choices have 1–3 keyboard shortcuts; tool approval cards are excluded. Active document state restores the exact chat-bound document; editor rich-only font controls and stats portal z-order are independent of chat tool authority. The research map SVG has an image role and accessible name, without proving complete accessibility.
Usage ledgers use nested Maps to preserve arbitrary legacy keys as data, while fresh writes require a nonempty session id and reject reserved session/run keys before accounting. JSON conversion preserves wire shape; metrics with a run id replace that run's previous value, while legacy writes without one add to aggregate totals and use per-object recorded/pending guards. Web Locks are used when available and bounded entries fold into totals, so evicted run replay is not forever deduplicated. Admin provider cards hide ChatGPT/recognized Featherless base URLs; optional fingerprints and displayed version/source commit are informational rather than byte attestation. Featherless catalog search is debounced/paginated with bounded results; optimistic pin/tool-mode PATCH saves and load-more handling do not guarantee acknowledged persistence or universal stale-response cancellation. Collapsed account usage cards retain loaded DOM, including errors, rather than proving fresh telemetry.
Markdown SVG uses an empty-sandbox, no-referrer iframe and restrictive CSP; title extraction is flat escaped text rather than parent-DOM parsing. Fenced and complete raw SVG are separate from generic HTML fences, with bounded aspect ratio. This does not confer the different permissions used by document print/run iframes. Remote mail rendering and image thumbnail assignment retain their own sanitizer/network boundaries.
Stylesheet helpers read the shell-ordered complete app cascade, exclude vendored libraries and share cache-token checks. Recursive lexical hygiene rejects deleted-monolith references with bounded exceptions; comment-stripped keyframe counting catches duplicate global names. Document-source helpers enumerate implementation files and scan braces lexically; quoted import/SW membership and minimum callable export tests are narrower than semantic module execution. The fixed computed-style snapshot covers 676 elements across its configured page/viewport/theme/density cases, freezes animation timing and checks a global rule-swap sensitivity count; it is not arbitrary browser/layout coverage. Manual benchmark HTML and historical screenshots are not newly reproduced acceptance.
The website has seven hover/focus/tap preview clips, ambient video, failure placeholders and thresholded swipe navigation. Reduced-motion CSS disables selected animation rather than guaranteeing all video stops. Restored branding/PWA assets do not prove license clearance or decoded-image review; the known `/backgrounds` absent-file/auth gap remains.
## Current Gaps
- The ordered stylesheet cascade and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
- There is no general build-time type checking or complete legacy script-order validation. `scripts/check_frontend.mjs` checks maintained-source imports, layer boundaries, explicit legacy exceptions, deployment URL/precache identity, and dependency cycles; its inventory mode reports legacy static imports/cycles. These gates cover the manifest-selected maintained graph, not every legacy module. Service-worker checks also cover stylesheet URLs and the email-library package.
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets, and for the `static/js/emailLibrary/` package only.
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
- `window.*` compatibility bridges remain widespread.
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
- Static regressions cover stylesheet references, selected script/module URL identities and maintained-output precache membership; complete legacy graph membership/evaluation and ongoing manifest icon drift still require checks.
- The static asset/route manifest regression covers stylesheets referenced by app-owned HTML and `sw.js`; scripts, modules and `manifest.json` icon references are still unverified.
+14 -33
View File
@@ -1,6 +1,6 @@
# Gallery, Editor, And Media
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -28,7 +28,7 @@ This spec covers media surfaces in:
- gallery upload, library, album, tag, favorite, ZIP, delete, and saved-project views;
- chat-generated image rendering/edit/delete bubbles;
- agent `generate_image` and owner-scoped `edit_image` tool paths;
- agent `generate_image` and stale `edit_image` tool paths;
- MCP image-generation rows/files;
- image editor AI tools and model endpoint pickers;
- document PDF signing with stored signatures;
@@ -51,13 +51,11 @@ Generated media provenance:
- upload `file_hash` is a separate metadata field;
- generated files live under `data/generated_images`;
- chat image generation writes files and inserts `GalleryImage` rows through `src.ai_interaction`;
- MCP image generation stamps the trusted dispatched owner when present, trims empty owner values to null, and can still create ownerless rows/files without that context;
- MCP image generation can create ownerless rows/files;
- generated-but-not-yet-imported images can have no gallery row;
- once a gallery row exists, owner checks decide visibility where the route enforces them.
`app.py` owns direct `/api/generated-image/{filename}` serving through `src.generated_images.resolve_generated_image_path()`. It validates filename syntax before ownership and disk probing. With auth enabled, `effective_user()` resolves cookie/API-token owners. A live registry entry is checked first: a matching owner authorizes, while a mismatch returns 404 without database fallback. Only absent registration reaches the gallery lookup: a nonempty row owner must match; a row without an owner instead requires a matching linked-session owner. If that lookup finds no gallery row, it requires a chat metadata reference in an owned session. Unattributed/mismatched files return 404 and database verification failures return 503. Disabled auth skips ownership verification. Confined path/existence checks follow authorization, and responses use `private, no-cache, must-revalidate` plus nosniff headers. Gallery replace/rotate/save/delete/ZIP paths also resolve filenames through a shared generated-image path helper so database filenames cannot escape `data/generated_images`. Replace/rotate/save-over-original flows can mutate bytes under the same filename, so frontend cache busting matters.
`src/generated_images.py` owns a lock-protected, process-local one-hour recent-owner registry. Image generation/edit writers in `src/ai_interaction.py` register trusted owners after writing bytes; the image MCP server stamps gallery ownership and registers after commit when an owner exists. Registry entries are ephemeral, not durable gallery ownership, and matching registry access can survive a database outage. Tool dispatch strips caller-reserved attribution keys before injecting verified owner context, including qualified image MCP and direct fallback paths.
`app.py` owns direct `/api/generated-image/{filename}` serving through `src.generated_images.resolve_generated_image_path()`. It validates hex-like image/video filenames, rejects path escape and missing files, serves rowless generated files, checks row owner when a row exists, allows null-owner compatibility rows, and uses immutable/nosniff cache headers. Gallery replace/rotate/save/delete/ZIP paths also resolve filenames through a shared generated-image path helper so database filenames cannot escape `data/generated_images`. Replace/rotate/save-over-original flows can mutate bytes under the same filename, so frontend cache busting matters.
## Image Tools And Providers
@@ -94,7 +92,7 @@ Optional dependency behavior:
## Editor Drafts
`routes.editor_draft_routes` owns server-backed image editor project payloads. `EditorDraft` rows store name, dimensions, payload JSON, thumbnail, source image, timestamps, and owner.
`routes.editor_draft_routes` owns server-backed image editor project payloads. `EditorDraft` rows store title, payload JSON, thumbnail, source image, timestamps, and owner.
Frontend editor behavior is split across `static/js/editor/*` and `static/js/galleryEditor.js`: canvas state, layer panel, masks, history, snapping, stroke pipeline, inpaint/rembg/harmonize tools, AI tool runner, model pickers, an AI edit command box that routes natural-language edit requests into existing inpaint/remove/upscale/background/style actions where possible, import wiring, topbar controls, auto-save, resume by draft ID or source image, draft-only open, and cleanup after close. `static/js/panels.js` loads this module graph on first editor use, shares concurrent imports, retries failed loads, and `static/sw.js` keeps the lazy graph in a separate offline panel precache.
@@ -128,11 +126,11 @@ Known boundaries:
- image-generation routes require `can_generate_images`;
- image proxy/editor endpoints currently resolve client-selected, DB-selected, or fallback image model endpoints without full owner-scoped endpoint-key policy or uniform outbound revalidation;
- rowless generated files require an owned chat reference or recent registration; null-owner gallery rows require a matching owned session;
- generated-file serving allows rowless files and null-owner compatibility rows;
- uploads are byte-limited and extension-gated, with content sniffing available through `UploadHandler.detect_content_type()` when `python-magic`/`libmagic` is installed;
- several base64 JSON editor routes accept large decoded image payloads and need route-level size discipline;
- gallery DB filenames should be joined through shared generated-media path helpers before filesystem operations;
- editor drafts owner-scope rows for identified users and cap serialized payload bytes, but source-gallery ownership and thumbnail size still need separate validation;
- editor draft source image IDs, payloads, and thumbnails are owner-scoped by draft owner but do not fully validate source-gallery ownership or payload size;
- emoji proxy constrains codepoint filenames and degrades invalid, unknown, or unreachable SVGs to transparent no-store placeholders, but remote SVG content still deserves security review;
- visual report Markdown HTML/link/image output needs continued sanitization coverage.
- `scripts/mlx_image_server.py` pins generation/edit routing to the process-start model and ignores request-selected model names, preventing unauthenticated callers from selecting a local model directory/repository whose model-specific script or bridge would execute.
@@ -144,41 +142,24 @@ Known boundaries:
- Missing generated files are skipped in ZIP downloads; if all are missing, the route returns no files found.
- Soft delete commits the gallery row state before removing the disk file, so a failed DB write does not orphan a missing image row.
- AI tagging can fail when disk files are missing.
- Static assets revalidate; most editor modules remain static ES modules, while `frontend/modules.json` and the committed generated-output freshness gate govern the manifest-selected native ES modules published by byte-for-byte copying to committed deployment outputs.
- Static JS/CSS/HTML assets revalidate because there is no frontend build/versioning.
- Gallery/editor frontend state includes stale-while-revalidate and listener cleanup to avoid stale handlers.
- `src/tools/image.py` implements `edit_image` in-process: prompt edits call the model adapter, upscale creates a 2x/4x Lanczos copy, and rembg creates a copy when the optional package is installed. It checks exact gallery owner and confined source path; uploaded attachment URIs support prompt edits after owner-aware resolution.
- `edit_image` tool schema/implementation currently appears stale against implemented `/api/image/*` and `/api/gallery/*` routes.
## Testing Coverage
Existing tests cover EXIF dimensions, owner-filter helper behavior, direct upload limits, image-generation privilege source shape, sharpen auth, gallery null-user denial, endpoint SSRF/source checks, editor draft payload validation, lazy editor loading/offline precache, MLX request-model pinning, font family derivation, visual-report helper behavior, gallery CLI previews, and selected security regressions.
Route-level coverage is thin for full gallery CRUD/album/tag/download/delete flows, generated-image serving, editor draft owner CRUD, signature owner CRUD, emoji proxy/cache behavior, image-tool degraded responses, optional dependency fallbacks, and full browser editor integration. Focused codec, layer/mask/group, transform, history, worker, and draft-budget regressions are present; this maintenance pass did not execute them.
## Versioned Editor Documents And Budgets
`static/js/editor/document-codec.js` is the shared project-file/autosave serialization boundary, currently document version 15. It migrates older versions, rejects future versions, validates layer/group/mask/selection and geometry data, and bounds canvas pixels, layer counts, total surfaces, and embedded image data. Masks, adjustments, effects, groups, vector text/shapes, placed layers and view/guide state pass through this codec rather than a second hand-written draft shape, subject to serialization/decode and history limitations below; this is not lossless persistence of every mask/effect property.
Editor responsibilities now include `document-geometry.js` and layer geometry for canvas transforms, `layer-groups.js`/`layer-selection.js` for grouping/multi-selection, `selection-mask.js` for selections, `text-layer.js`/`shape-layer.js` for vector content, and `adjustment-layer.js`/`effects.js` with worker paths for compositing operations. `history-budget.js` bounds raw ImageData history adaptively; rendering cancellation prevents stale asynchronous results from applying after newer edits.
`routes/editor_draft_routes.py` uses `EDITOR_DRAFT_MAX_BYTES` both for early rejection of oversized declared Content-Length on writes and for the authoritative serialized-payload byte check. Missing/malformed Content-Length still reaches the latter check after parsing. This cap does not supply source-gallery ownership or a separate thumbnail budget.
`src/media_ingress.py` separately builds bounded local multimodal attachments for supported image/video/audio/document paths with source hashes, encoding/dimension/frame limits, and artifact metadata. Agent evidence/trace consumers record ingress metadata; it is not the browser upload storage or editor serialization owner.
## Editor Persistence And Operation Limits
Codec limits count canvas/surface allocations and embedded strings rather than universally bounding decoded bytes. Failed canvas encoding or image restoration can omit/drop data; effect-mask normalization and history snapshots do not preserve every mask density/feather property. Async capture can span intervening edits, so it is not an immutable atomic pixel snapshot. History deduplicates shared pixel buffers and trims older snapshots to a budget while retaining the newest even when oversized; it does not serialize the entire history into a draft.
Draft autosave reserves one in-flight capture and uses session/generation checks for assigning returned ids, with PUT404 recreation. Those checks do not cancel an old server request. The current persist chain can clear an earlier error and display Saved after catch/early return; the badge is not a durable-write receipt. Project state uses server draft ids and a compact session descriptor rather than a full browser-sessionStorage payload. Restore can drop failed raster/mask/placed sources and is not a lossless project recovery promise.
Placed transforms, crop/resize/flip, linked versus document-space masks, grouped effects and rasterization retain distinct geometry paths. Duplicate/merge/copy operations do not guarantee independent preservation of every effect-mask/retained metadata field; raw layer copying differs from flattened canonical output. Pixel/position locks are checked on selected mutation paths rather than proving every control respects every lock. Escape cancellation is operation-specific (including crop/transform/filter prompts), not a universal rollback guarantee. Filter/worker generations prevent selected stale results, but shared intermediate canvases and Quick Edit busy-end polling do not establish atomic pixels or successful downstream AI work.
Route-level coverage is thin for full gallery CRUD/album/tag/download/delete flows, generated-image serving, editor draft owner CRUD, signature owner CRUD, emoji proxy/cache behavior, image-tool degraded responses, optional dependency fallbacks, and frontend editor behavior.
## Current Gaps
- Owner-scoped endpoint-key resolution is needed for image proxy/editor routes.
- Media routes need a clear API-token policy: reject token callers, or implement owner/scope handling.
- Direct handler ownership/cache tests cover generated-image denial and fallback branches; middleware-mounted serving, MIME/account-switch and live browser cache behavior still need broader checks.
- Private revalidation replaces immutable generated-media caching; replace/save-over-original and account-switch behavior still warrant browser cache tests.
- Generated-image serving needs live route tests for invalid filenames, rowless files, owned rows, null-owner rows, MIME/cache headers, and cross-owner behavior.
- Mutable generated filenames plus immutable cache headers need cache-busting tests for replace/save-over-original flows.
- Base64 JSON editor payload size limits need hardening; upload content sniffing should keep native/Docker parity coverage as dependencies change.
- Image MCP now receives trusted server-dispatch ownership; direct out-of-band MCP callers and cross-process recent-registry behavior remain separate trust/availability limits.
- MCP image generation needs an owner attribution decision or explicit admin-only documentation.
- `edit_image` tool route mapping appears stale.
- Emoji SVG proxy/cache and visual-report raw HTML/link sanitization need stronger tests.
- Optional image dependency fallbacks are mostly untested.
+12 -23
View File
@@ -1,6 +1,6 @@
# Integrations
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -80,11 +80,17 @@ The Cookbook scoped-agent surface currently exposes `cookbook:read` and `cookboo
`src.integrations` owns generic API integration presets, `data/integrations.json`, API-key encryption/decryption, secret masking, plaintext-key migration, enabled integration prompt text, and `execute_api_call()`.
`routes.auth_routes` owns HTTP CRUD/test routes for these integrations, requiring an admin unless auth is explicitly disabled. Presets are public metadata. The ntfy test route is special: it publishes a real test notification to the configured reminder topic instead of only probing server health.
`routes.auth_routes` owns admin-only HTTP CRUD/test routes for these integrations. Presets are public metadata. The ntfy test route is special: it publishes a real test notification to the configured reminder topic instead of only probing server health.
`api_call` is the agent/tool execution path for configured integrations. It is blocked for non-admin/public users by tool security, accepts only relative paths, uses the admin-configured base URL/auth settings, and returns truncated external responses to the model, including a sentinel when long JSON lists are shortened. Admin-authored integration descriptions are prompt context; external responses remain untrusted data.
`execute_api_call()` normalizes base URLs to HTTP(S) scheme, hostname, and path-only values, rejects request paths that are not relative absolute paths (`/...`) or that carry schemes/fragments, treats `/` as the base URL without appending an extra slash, and checks the final URL through `src.url_safety`. Link-local/metadata targets are always rejected; setting `INTEGRATION_API_BLOCK_PRIVATE_IPS=true` also rejects loopback/RFC1918/private addresses for operators who do not need LAN integrations.
`execute_api_call()` normalizes base URLs to HTTP(S) scheme, hostname, and
path-only values, rejects request paths that are not relative absolute paths
(`/...`) or that carry schemes/fragments, treats `/` as the base URL without
appending an extra slash, and checks the final URL through `src.url_safety`.
Link-local/metadata targets are always rejected; setting
`INTEGRATION_API_BLOCK_PRIVATE_IPS=true` also rejects loopback/RFC1918/private
addresses for operators who do not need LAN integrations.
After validation, `execute_api_call()` pins the outbound connection to the validated IP snapshot while preserving the configured URL, Host header, TLS server name, and redirect policy. DNS cannot select a different destination between SSRF validation and transport.
@@ -153,7 +159,8 @@ This spec owns the cross-integration framing and agent/token/webhook surfaces. D
- Missing or corrupt `data/integrations.json` loads as an empty list; non-object rows are ignored.
- Plaintext generic integration API keys migrate to encrypted storage on load.
- Webhook delivery has no retry/backoff queue; the persisted state is last status or sanitized last error.
- Webhook URLs are validated at create and delivery time, redirects are disabled, and delivery connects to the IP set validated immediately before the request.
- Webhook URLs are validated at create and delivery time, redirects are disabled,
and delivery connects to the IP set validated immediately before the request.
- Companion LAN detection is best-effort and falls back to local host/port defaults unless a valid `COMPANION_BASE_URL` is configured.
- `ODYSSEUS_URL` must be reachable from the external coding agent; no Docker/native URL rewrite is performed.
@@ -175,30 +182,12 @@ Current targeted coverage includes API-token CRUD basics, chat-scoped `/api/mode
The integration audit also ran the targeted venv subset covering those areas with 52 passing tests and one warning.
## Bound External Backends
Tool-backed integration calls consume `src.agent_runtime.remote_resources` backend identity. `src.integrations.execute_api_call()` refuses a bound call when the saved integration identity differs from the admitted resource; a changed configuration cannot silently inherit an earlier approval. MCP and client/host bridges have their own producer/configuration identities and do not gain permission from advertised schemas.
Scoped Cookbook adapters reject internal-tool transport/header calls instead of treating internal owner attribution as an external scoped credential. In auth-disabled mode they still require the native local-operator boundary before borrowing endpoint functions. Public API-token scopes remain distinct from browser admin and internal-loopback authority.
## Adapter Admission Limits
Generic `app_api` refuses decoded `/api/codex/cookbook` selectors before dispatch, so it cannot wrap the dedicated scoped adapter to borrow its permissions. Integration requests preserve their explicit query/body inputs and owner/backend binding; an observed `api_call` response is an external acknowledgement, not sealed proof of remote poststate. Public scoped credentials, browser admin access and native local operator admission remain separate boundaries.
## App Configuration Compatibility
Integration list/create/update/delete/connectivity-test handlers in `routes/auth_routes.py` use `_require_admin_user()`: explicit disabled-auth operator mode is admitted without a session, while auth-enabled access still requires an admin. Integration listing continues masking keys. This app-configuration exception does not relax user-store administration or scoped external API authorization.
## Delegated Upload Access
`routes/upload_routes.py` applies a separate delegated-token owner/scope gate to browser-compatible upload endpoints. `chat` or `documents:write` permits creation/OCR edits; either of those or `documents:read` permits download/thumbnail/OCR reads. Scope is an any-of intersection, not a requirement for every listed scope. Tokens remain confined to their actual owner's non-null-owned files even when the owner is an interactive admin; invalid/missing/sentinel token identity fails closed. This supplements global token middleware rather than changing other scoped API contracts.
## Current Gaps
- Codex/Claude scoped routes, owner restoration, degraded 503 behavior, plugin zip contents, and helper-script path refusal need focused regression tests.
- Token profile/update behavior and Settings agent-token scope toggles need direct coverage.
- Codex Cookbook scopes need continued Settings, route-check, and `ALLOWED_SCOPES` regression coverage.
- Generic integration route fixtures cover CRUD/test admission with auth enabled and disabled, using substituted stores and network calls. Live notification delivery, `execute_api_call()` auth modes, response shaping, and frontend Settings/Admin integration still need broader coverage.
- Generic integration HTTP CRUD/test routes, `execute_api_call()` auth modes, response shaping, and frontend Settings/Admin flows need direct coverage.
- `do_manage_tokens()` does not match `/api/tokens` semantics for `ody_` prefix, owner, scopes, and cache invalidation.
- `do_manage_webhooks()` bypasses route behavior and does not cover signing-secret parity.
- Companion read endpoints should either require `chat` scope or be documented as an explicit scope-policy exception.
+33 -29
View File
@@ -1,6 +1,6 @@
# LLM Models And Endpoints
Last updated: dev@86b886c | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -10,7 +10,7 @@ This spec covers model/provider behavior in:
- `src/endpoint_resolver.py`;
- `src/foreground_model_routing.py`;
- `src/model_discovery.py`;
- `src/model_context.py`, `src/generation_budget.py`, `src/model_pricing.py`, `src/model_profiles.py`, and `src/utility_effort.py`;
- `src/model_context.py`;
- `src/model_capabilities.py`;
- `src/model_capability_readers/`;
- `src/task_endpoint.py`;
@@ -32,19 +32,41 @@ This spec covers model/provider behavior in:
`llm_core` owns payload shape. Route files and chat/agent code should request a call; they should not duplicate provider-specific payload quirks.
Kimi Code User-Agent discovery has both sync and async implementations. Async post and stream paths probe `/models` through their existing async client and await each candidate, so header negotiation does not block the event loop; both paths share the accepted-value cache and 403 fallback policy.
Kimi Code User-Agent discovery has both sync and async implementations. Async
post and stream paths probe `/models` through their existing async client and
await each candidate, so header negotiation does not block the event loop; both
paths share the accepted-value cache and 403 fallback policy.
Provider-specific behavior is part of this layer: `LLM_CONNECT_TIMEOUT` controls the connect budget for sync and streaming calls, Kimi Code endpoints retry a small whitelisted User-Agent set on 403 and cache the accepted value, official Moonshot/Kimi Code and Anthropic Opus 4.7+ payloads omit sampling controls where required, and major-only Opus IDs such as `claude-opus-5` also omit temperature instead of falling through numeric minor-version parsing. Reasoning models omit or clamp unsupported temperature values, while self-hosted compatible endpoints keep normal parameters unless detected otherwise. Mistral structured content is normalized in async utility calls as well as stream/chat paths, and Mistral/Moonshot/Kimi reasoning content, `gpt-oss` harmony output, DeepSeek V4 thinking identifiers, and native/OpenAI-compatible Ollama thinking formats keep hidden reasoning separate from visible text. Tool names that collide with GPT-OSS built-ins are aliased on the provider boundary and mapped back before execution. Copilot request metadata remains defensive against malformed `request_flags`.
## Canonical Provider And Model Shape
`src.model_capabilities` owns canonical model family, task, modality, capability, limit, evidence, assertion, deterministic-control, probe-result, reasoning-control token, and display-query values. `src.model_capability_readers` owns endpoint-scoped stable identity, lightweight provider detection, record serialization, and normalization of already-fetched provider payloads. Readers do no network I/O. Model-specific observations are kept in `model-quirks.md`, not a runtime registry without a consumer.
`src.model_capabilities` owns canonical model family, task, modality,
capability, limit, evidence, assertion, deterministic-control, probe-result,
reasoning-control token, and display-query values.
`src.model_capability_readers` owns endpoint-scoped stable identity, lightweight
provider detection, record serialization, and normalization of already-fetched
provider payloads. Readers do no network I/O. Model-specific observations are
kept in `model-quirks.md`, not a runtime registry without a consumer.
Provider support and model support are different facts. A provider may expose tools, reasoning, vision, or multiple APIs while individual models differ. Provider-native readers describe where model evidence can appear. Current concrete readers cover generic OpenAI-compatible identity, OpenAI, OpenRouter, Google, Ollama, LM Studio, and llama.cpp. Identity-only model lists remain unknown.
Provider support and model support are different facts. A provider may expose
tools, reasoning, vision, or multiple APIs while individual models differ.
Provider-native readers describe where model evidence can appear. Current
concrete readers cover generic OpenAI-compatible identity, OpenAI, OpenRouter,
Google, Ollama, LM Studio, and llama.cpp. Identity-only model lists remain
unknown.
Reader dispatch uses an explicit vendor first, then endpoint kind, label-bounded hostname suffix, and common local-port hints. Generic payload handling accepts `data[]` or `models[]` items with `id`, `name`, or `model`; it does not accept a bare list and never promotes capability-looking fields. Unknown fields remain in the in-memory raw record. See [model-capability-canonical.md](model-capability-canonical.md), [model-quirks.md](model-quirks.md), and the [provider map](model-providers/_readme.md).
Reader dispatch uses an explicit vendor first, then endpoint kind, label-bounded hostname suffix, and common local-port hints. Generic payload handling accepts `data[]`
or `models[]` items with `id`, `name`, or `model`; it does not accept a bare
list and never promotes capability-looking fields. Unknown fields remain in
the in-memory raw record. See [model-capability-canonical.md](model-capability-canonical.md),
[model-quirks.md](model-quirks.md), and the
[provider map](model-providers/_readme.md).
Canonical records are exercised by focused unit tests but are not wired into runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers. The reader package’s `detect_vendor()` helper does have narrow runtime consumers in `src/ai_interaction.py` and gallery routes for OpenRouter image transport; that does not integrate canonical capability records. `routes/model_routes.py` model probes continue to return model IDs through their existing runtime path.
This canonical layer is currently exercised by focused unit tests but is not
wired into runtime discovery, endpoint resolution, model context, request
shaping, or frontend pickers. `routes/model_routes.py` model probes continue to
return model IDs through their existing runtime path.
Route-level probe helpers in `routes/model_routes.py` are the current exception: they build minimal provider-specific probe payloads using `llm_core` detection helpers. Keep probe behavior aligned with `llm_core` provider adapters. LLM provider HTTP clients and endpoint probes share `src.tls_overrides.llm_verify()`, which can add an operator-provided `LLM_CA_BUNDLE` on top of normal certificate verification without turning verification off or widening that trust to arbitrary URL fetches.
@@ -64,7 +86,7 @@ OpenAI-compatible model-list URL construction preserves `/v1` bases and inserts
`routes/session_routes.py` owns binding sessions to endpoint IDs, owner-scoped header construction, raw-endpoint rejection for non-admin users, model validation, and persisted session headers. Compare panes and normal chat session creation use this path.
`ModelEndpoint` rows own API keys, base URLs, cached/hidden/pinned models, model type, endpoint kind, refresh mode/interval/timeout, supports-tools state, per-model tool-mode overrides, nullable owner, optional provider-auth linkage, and provider metadata. `owner = NULL` means legacy/shared; non-null rows are private to that owner, while ordinary admin inventory can see all. ChatGPT Subscription rows are an exception: account visibility requires exact effective-owner equality even for admins. Secret fields must remain encrypted and scrubbed in responses.
`ModelEndpoint` rows own API keys, base URLs, cached/hidden/pinned models, model type, endpoint kind, refresh mode/interval/timeout, supports-tools state, nullable owner, optional provider-auth linkage, and provider metadata. `owner = NULL` means legacy/shared; non-null rows are private to that owner, while admins can see all. Secret fields must remain encrypted and scrubbed in responses.
Decrypted endpoint headers can be copied into session metadata for chat use. Endpoint deletion must clear dependent settings and copied session headers.
@@ -72,7 +94,7 @@ Decrypted endpoint headers can be copied into session metadata for chat use. End
`src.model_discovery` owns host/env/Tailscale/local-port scanning for model servers. Admin `/api/providers` and `/api/discover` use that scanner; endpoint CRUD, test, refresh, and hidden-model controls are frontend-owned by `static/js/admin.js`.
`/api/models` is the normal picker/catalog surface. It is auth/owner scoped, per-user/admin-flag cached briefly, can trigger background refresh, preserves offline endpoint rows, filters hidden models, and preserves pinned model IDs for UI selection. API-token callers must carry `chat` scope and a token owner before they can list models. API/proxy endpoint inventory is visible by default until an explicit `pinned_models` allow-list is saved; an explicit empty list means show none, and legacy hidden-list state is upgraded to the equivalent pins so endpoint settings, picker checkboxes, and chat agree. Proxy/API endpoints can be marked cached-first/manual so large upstream catalogs are not repeatedly probed, while explicit refresh paths use longer manual timeouts. Local endpoints get cheap reachability probes before expensive refreshes where possible, and endpoint responses can include explicit `supports_tools` state for schema-emission heuristics. Google Gemini API endpoints use the native paginated `generativelanguage.googleapis.com/v1beta/models` catalog, send API keys in `x-goog-api-key`, retain only content-generation model IDs, and default to manual refresh unless the caller explicitly chooses another mode. Probe failure returns no curated Google fallback. `static/js/models.js` and `static/js/modelPicker.js` own the sidebar/picker catalog; `static/js/model/matchKey.js` owns longest-substring model-info/pricing key matching; `static/js/settings.js` coordinates default, utility, vision and fallback selectors; `static/js/settings/imageModels.js` and `static/js/settings/speech.js` own their extracted image and TTS/STT panels.
`/api/models` is the normal picker/catalog surface. It is auth/owner scoped, per-user/admin-flag cached briefly, can trigger background refresh, preserves offline endpoint rows, filters hidden models, and preserves pinned model IDs for UI selection. API-token callers must carry `chat` scope and a token owner before they can list models. API/proxy endpoint inventory is visible by default until an explicit `pinned_models` allow-list is saved; an explicit empty list means show none, and legacy hidden-list state is upgraded to the equivalent pins so endpoint settings, picker checkboxes, and chat agree. Proxy/API endpoints can be marked cached-first/manual so large upstream catalogs are not repeatedly probed, while explicit refresh paths use longer manual timeouts. Local endpoints get cheap reachability probes before expensive refreshes where possible, and endpoint responses can include explicit `supports_tools` state for schema-emission heuristics. Google Gemini API endpoints use the native paginated `generativelanguage.googleapis.com/v1beta/models` catalog, send API keys in `x-goog-api-key`, retain only content-generation model IDs, and default to manual refresh unless the caller explicitly chooses another mode. Probe failure returns no curated Google fallback. `static/js/models.js` and `static/js/modelPicker.js` own the sidebar/picker catalog; `static/js/model/matchKey.js` owns longest-substring model-info/pricing key matching; `static/js/settings.js` owns default, utility, vision, image, TTS, STT, and fallback selectors.
`src.task_endpoint` owns background-task endpoint/model resolution for task routes and scheduler callers. It resolves `task_endpoint_id`/`task_model` through the normal endpoint resolver with owner context.
@@ -80,7 +102,7 @@ Cookbook and HWFit own local model download, serve, ranking, and auto-registrati
## Context Length
`src.model_context` owns model context-length lookup/query and token estimation. Cache keys include endpoint plus model so identical model names on different endpoints do not bleed context-window data. Local llama.cpp context probes try `/slots` and then `/props.default_generation_settings.n_ctx`. Unknown proxy/API models can pick up real context windows from endpoint catalog metadata such as `context_length`; otherwise unknown lengths stay explicit unknowns rather than default values. Known lengths feed chat/agent token-budget scaling through `src.context_budget`. Token estimation counts assistant `tool_calls` arguments so compaction sees tool-only turns instead of underestimating them. Chat/agent context budgeting should call this layer instead of hardcoding model windows.
`src.model_context` owns model context-length lookup/query and token estimation. Cache keys include endpoint plus model so identical model names on different endpoints do not bleed context-window data. Unknown proxy/API models can pick up real context windows from endpoint catalog metadata such as `context_length`; otherwise unknown lengths stay explicit unknowns rather than default values. Known lengths feed chat/agent token-budget scaling through `src.context_budget`. Token estimation counts assistant `tool_calls` arguments so compaction sees tool-only turns instead of underestimating them. Chat/agent context budgeting should call this layer instead of hardcoding model windows.
## Runtime Fallback And Routing
@@ -105,18 +127,6 @@ Provider tool calls are untrusted requests, not authorization. `supports_tools`
- SSE/stream parsers tolerate null choice/usage/tool-call entries and null streaming tool-call arguments; provider events should degrade to empty text or shaped stream errors instead of crashing the chat loop.
- Provider adapters carry small model-specific quirks: Opus 4.7+ and official Kimi/Moonshot code payloads omit `temperature`, Kimi/Moonshot/Mistral reasoning content is preserved separately, ChatGPT Subscription refreshes bearer credentials, native Ollama can handle multimodal content, and Ollama `/v1` responses for Qwen3/Gemma4-style thinking can suppress thinking text when requested.
## Additional Runtime Owners
`src.generation_budget` estimates native tool-schema and image reserve costs, fits output limits to the remaining context budget, and parses provider context errors for bounded recovery. `llm_core` uses these helpers in async/streaming paths; recovery is separate from switching to a fallback model. `src.model_context.estimate_text_tokens()` counts non-ASCII characters individually and Latin characters at roughly 0.3 tokens each, including tool-call names/arguments. These remain estimates, not tokenizer guarantees.
`src.model_profiles` selects the Odysseus compact schema and progressive-thinking profile from bounded `odysseus`/`ajax` tokens in the model ID leaf. `supports_user_thinking_toggle()` uses explicit model-family patterns and suppresses the toggle for that profile and GPT/o-series leaves; these are runtime heuristics outside the canonical reader. Endpoint `model_tool_modes` accepts `none`, `compact`, or `full` per model, with legacy aliases normalized by model routes. This is schema/routing configuration, not tool execution authority.
`src.utility_effort` resolves owner-aware utility effort, including fallback-specific overrides and eligible session-route effort. Validation currently uses ChatGPT model metadata, including maintained defaults and model-name fallback metadata; it is not evidence from the canonical reader. `src.model_pricing` owns server-side known-price estimates; unknown models return no estimate and local/subscription endpoints are excluded from per-token cost tracking.
Featherless endpoints use an explicit pin inventory instead of bulk catalog refresh. Admin `GET /api/model-endpoints/{ep_id}/catalog-search` searches active conversational models available on the current plan with bounded pagination and a 45-second in-memory query cache. New Featherless rows default to an empty pin list. `/api/models?refresh=true&wait_refresh=true` can wait up to 15 seconds on the shared background refresh.
`src.endpoint_resolver.resolve_owner_registered_endpoint()` accepts only an enabled owner-visible registered base or its derived chat URL, returning the server-owned row. URL identity includes scheme, host, effective port, and path; caller credentials, query strings and fragments are rejected. `same_endpoint_base()` governs exact origin/base-path credential reuse. Native runtimes can rewrite persisted `host.docker.internal` bases to loopback.
## Security Policy
- Endpoint API keys are encrypted in `ModelEndpoint.api_key` and never returned by endpoint APIs; admin surfaces return key presence only.
@@ -133,15 +143,9 @@ Featherless endpoints use an explicit pin inventory instead of bulk catalog refr
- utility model fallbacks for summarization/extraction;
- frontend Settings and model picker endpoint management.
## Discovery And Route Identity Details
`src.model_discovery` timestamps successful Tailscale host queries, including an empty result, for the 60-second cache. A failed query does not become a successful empty observation. This discovery cache is separate from endpoint URL resolution and model-context probe caches.
Exact registered endpoint authority rejects URL credentials, parameters, and any query or fragment delimiter, including an empty one; it accepts the registered base or an explicitly derived inference route rather than broad same-host aliases. Owner-visible shared endpoint rows remain distinct from exact-owner-only data. Utility effort forwarding remains explicit at participating callers; a configured off/none/default selection can suppress session effort rather than being treated as a missing override.
## Current Gaps
- Runtime provider detection, model curation, and frontend logos are still split across `llm_core`, `model_routes`, and `providers.js`; canonical records have no runtime model-catalog consumer yet, while provider detection is reused by media paths.
- Runtime provider detection, model curation, and frontend logos are still split across `llm_core`, `model_routes`, and `providers.js`; the canonical reader package has no production consumer yet.
- Provider-specific behavior is concentrated in `llm_core.py`, which is large and easy to regress.
- Several runtime request builders still use model-name heuristics. They should migrate only after endpoint/provider code supplies structured identity and a real consumer contract; the canonical catalog does not add a parallel quirk matcher.
- Endpoint identity and fallback behavior need careful review when new OAuth/subscription providers are added.
+10 -39
View File
@@ -1,6 +1,6 @@
# Memory And Skills
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
@@ -14,7 +14,7 @@ This spec covers persistent memory and user skills in:
- provider abstractions in `src/memory_provider.py`;
- LLM extraction/audit in `services/memory/memory_extractor.py`;
- skill storage, format, import, and extraction in `services/memory/skills.py`, `services/memory/skill_format.py`, `services/memory/skill_importer.py`, and `services/memory/skill_extractor.py`;
- skill HTTP/compatibility bindings in `routes/skills_routes.py` and evaluation/audit/job/model/prompt/publication services under `services/skills/`;
- skill routes in `routes/skills_routes.py`;
- prompt/tool call sites in `src/chat_processor.py`, `src/agent_loop.py`, `src/ai_interaction.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_security.py`;
- MCP and Codex surfaces in `mcp_servers/memory_server.py` and `routes/codex_routes.py`;
- backup/admin/CLI surfaces in `routes/backup_routes.py`, canonical `routes/admin_wipe/admin_wipe_routes.py` plus its shim, `scripts/odysseus-memory`, `scripts/odysseus-skills`, and `scripts/odysseus-backup`;
@@ -43,23 +43,25 @@ Extraction handles reasoning-model response shapes and records explicit dislike/
`services/memory/skills.py` owns disk-backed skill storage under `data/skills/<category>/<name>/SKILL.md`, plus `_usage.json` usage/audit sidecars. Legacy `data/skills.json` is a read-only fallback/import source, not the current write shape.
`services/memory/skill_format.py` owns frontmatter/body parsing and emission. Quoted scalar parsing/emission is symmetric: JSON escapes decode once, UTF-8/non-ASCII stays intact, emitted values escape line separators safely, and invalid JSON-style escapes fall back to literal text instead of compounding backslashes on every save. `services/memory/skill_importer.py` resolves public GitHub/skills URLs, fetches bundle files with strict public-network URL safety, and chooses/imports `SKILL.md`. Import disables automatic redirects, follows at most five hops, validates and resolves each hop, then connects only to the validated IP snapshot through a pinned transport while preserving URL, Host, and TLS identity; GitHub final-host checks and file/size limits still apply. `routes/skills_routes.py` owns HTTP CRUD/search/index/import, authentication/owner verification, exact approval validation, request models and admin-gated built-in instruction overrides. Evaluation, execution, job coordination, audit sequencing and publication policy now live under `services/skills/`; durable identity/storage/lifecycle remains in `services/memory/skills.py`.
`services/memory/skill_format.py` owns frontmatter/body parsing and emission. Quoted scalar parsing/emission is symmetric: JSON escapes decode once, UTF-8/non-ASCII stays intact, emitted values escape line separators safely, and invalid JSON-style escapes fall back to literal text instead of compounding backslashes on every save. `services/memory/skill_importer.py` resolves public GitHub/skills URLs, fetches bundle files with strict public-network URL safety, and chooses/imports `SKILL.md`. Import disables automatic redirects, follows at most five hops, validates and resolves each hop, then connects only to the validated IP snapshot through a pinned transport while preserving URL, Host, and TLS identity; GitHub final-host checks and file/size limits still apply. `routes/skills_routes.py` owns CRUD/search/index/import, owner filtering, skill test/audit jobs, and admin-gated built-in tool instruction overrides.
Skill extraction is owned by `services/memory/skill_extractor.py`. It can suggest or save skills from conversations, tries valid brace-delimited JSON candidates with `JSONDecoder.raw_decode()`, rejects ambiguous multiple top-level JSON objects instead of guessing, and saved skills remain user-editable data.
Agent skill behavior:
- matched skills are owner-scoped, confidence-gated, usage-counted, and wrapped as untrusted context;
- `index_for()` exposes built-ins and eligible user skills gated by current approval policy, platform, local-model status, and toolsets; `active_toolsets=None` means the caller has no explicit toolset knowledge and does not hide `requires_toolsets` skills, while an explicit list applies the gate;
- `index_for()` exposes published skills plus teacher-escalation drafts gated by platform and toolsets; `active_toolsets=None` means the caller has no explicit toolset knowledge and does not hide `requires_toolsets` skills, while an explicit list applies the gate;
- user prefs such as skills enabled, auto-approve, and max injected skills shape runtime insertion;
- the level-0 skill index passes the acting owner into `index_for()`, applies active-toolset/platform and local-model gates, and returns its user-editable index block separately for untrusted-context wrapping.
- the level-0 base skill index currently calls `index_for(owner=None)`, so it is not fully owner-scoped.
- skill tests use the configured utility model rather than the chat default and wrap user-editable skill text as untrusted context; approval continuation for a test or teacher-generated skill uses the same exact-action gate as the normal agent loop.
## Tools, MCP, And Backup
Native `manage_memory` and `manage_skills` tool paths pass owner context and use in-process policy gates. `manage_skills` requires an explicit action instead of silently defaulting a malformed call. Manual memory add can choose a category, and route-side manual add validates the source session owner before attaching session-derived memories. `mcp_servers/memory_server.py` lazy-initializes `src` managers and exposes list/add/edit/delete/search. It can scope to `ODYSSEUS_MCP_MEMORY_OWNER` or `ODYSSEUS_MEMORY_OWNER`; if the JSON store contains owner-bearing entries and no owner env is configured, it returns an owner-scope error instead of listing or mutating across owners. Ownerless stores remain ownerless compatibility mode.
The direct `odysseus-memory add` CLI tolerates non-object legacy/corrupt rows when checking whether its newly added entry is already present; it ignores those rows instead of calling mapping methods on them and crashing the add.
The direct `odysseus-memory add` CLI tolerates non-object legacy/corrupt rows
when checking whether its newly added entry is already present; it ignores
those rows instead of calling mapping methods on them and crashing the add.
`/api/export` owner-filters memories and skills. `/api/import` imports skills through current disk-backed `SkillsManager` APIs, stamping missing owners to the importer and preserving supported skill metadata. Full data snapshots through `scripts/odysseus-backup` preserve on-disk skill trees, memory JSON, and caches differently from JSON import/export.
@@ -90,7 +92,7 @@ Owner isolation is surface-specific:
- native memory/skill tools are expected to pass owner context;
- Codex exposes scoped token memory behavior separately;
- normal memory/skills routes are cookie/current-user surfaces, not scoped token APIs;
- MCP memory uses an environment-configured owner for owner-scoped stores, while the agent level-0 skill index now passes the acting owner;
- MCP memory uses an environment-configured owner for owner-scoped stores, while the agent level-0 skill index currently has ownerless/global behavior;
- vector dedup during memory extraction suppresses only same-owner or legacy-ownerless vector matches.
Skill test/audit flows intentionally run user-editable `SKILL.md` content as instructions inside controlled jobs. Those jobs rely on route owner checks, admin gates where applicable, and tool execution policy.
@@ -105,41 +107,10 @@ Existing tests cover memory extraction/degraded vectors, owner isolation, unread
Route-level memory CRUD/security, skills route security, MCP memory behavior, vector degraded writes, compatibility facade owner behavior, backup skill import, admin vector cleanup, and frontend endpoint wiring need broader coverage.
## Skill Approval, Retrieval, And Persona Continuity
`services/memory/builtin_skills.py` synchronizes tracked `BUILTIN_SKILLS_DIR` procedures at startup into an immutable ownerless shared catalog, published with confidence 1.0. Owner-aware list/view paths expose these built-ins alongside the owner's skills; built-ins skip user audits. User-editable memories and imported/extracted skills retain their untrusted-context treatment.
`SkillsManager.index_for()` excludes failing user audits and unnecessary skills. With automatic approval enabled, a user skill needs published status, a passing audit, and confidence at the owner's current threshold; a stale published flag alone is insufficient. Manual publication supplies approval when automatic approval is disabled. `remote-only` skills are hidden from local/private-endpoint model indices. Search also gates toolsets and platforms, combining lexical scores with optional semantic scores from `src/skill_index.py`; semantic retrieval reuses an already-ready ToolIndex embedding lane and falls back to lexical search without a foreground cold model load.
Skill audit protocol v2 separates functional pass/fail from no-skill baseline utility, storing summary, baseline verdict, usefulness, saved turns/tool calls, and audit version in `_usage.json`. Legacy inconclusive v1 results are eligible for re-audit. `services/memory/skill_lifecycle.py` supplies a bounded automatic queue: transient/inconclusive checks retry daily, failed/needs-work repairs weekly, and built-in/binned/pass/skipped entries are excluded. Route jobs preserve request authority and exact-action approval continuation.
Memory extraction automatically pins new identity/contact facts only while the count of already-pinned identity/contact entries for the acting owner and compatible ownerless entries is below five; it does not unpin older entries. Extra facts remain retrievable by relevance. Persona continuity extraction stores concise notes in preset/persona data through `PresetManager.update_persona_memory()`, separately from global memory JSON; health personas use a cautious medical brief schema and persona deletion removes its notes.
## Mutation And Audit Lifecycle Limits
Memory edit/delete paths reject empty identities instead of allowing an empty substring match, and mutating operations preserve unreadable-store refusal. Persona extraction updates an existing persona rather than creating a missing persona implicitly; persona notes remain separate from global memory.
`services/skills/publication.py` implements batch finalization behind the route compatibility wrapper `_finalize_audit_batch()` and changes only skills processed by that job, protects approval-required results, and retains binned duplicates on disk while excluding them from retrieval. Name normalization and a 0.38 token-Jaccard threshold group potential duplicates; this heuristic is not semantic equivalence. Functional success and baseline usefulness remain separate audit dimensions, so a successful baseline does not itself make the skill functionally inconclusive.
`SkillAuditUnavailable` stops the batch without treating infrastructure failure as a skill verdict. Other audit errors can record inconclusive results. Manual test and audit agent streams use `contextlib.aclosing`, including early exits for exact-action approval, so the originating agent task closes rather than remaining suspended behind a pending choice. These lifecycle guarantees do not establish successful model execution when the provider or containment backend is unavailable.
## Skill Test Endpoint Recovery
Skill tests prefer the configured utility route; their registered fallback resolves server-owned runtime credentials and does not trust caller-supplied provider headers. The audit-arm path raises `SkillAuditUnavailable` on stream failure and stops the batch without a skill verdict. Manual primary-stream exceptions instead retain a partial transcript for subsequent baseline/evaluation; if grading returns `fail`, confidence can still decrease. An unavailable result is not guaranteed for every manual-test failure.
## Skills Service Boundaries
`services/skills/evaluation.py` owns verdict parsing/grading, necessity/retrieval assessment, efficiency fields and markdown improvement. `agent_runner.py` owns manual/audit agent streams, transcripts, request/exact-approval authority and same-task `aclosing`. `audit_workflow.py` owns per-skill checks, self-edit/worker retry, optional distinct-teacher rewrite, sequential batches and scheduled selection. `job_coordinator.py` owns transient job transitions, candidate selection, safe polling projections and cancellation requests. `model_selection.py` resolves owner-scoped worker/teacher credentials and utility fallback; `prompts.py` constructs tasks and explicitly untrusted skill messages. `publication.py` applies confidence/duplicate/publishing policy through SkillsManager; its preferences adapter still imports `routes.prefs_routes`.
Routes retain patchable aliases/wrappers, including the service-owned manual/audit registry dictionaries and `SkillAuditUnavailable`. A frozen `AuditDependencies` is built from current route bindings at service entry, preserving caller/test seams without rebinding service globals or importing the Skills route module from services. `app.py` still registers the same router and built-in actions still use route compatibility helpers. Registries are process-local jobs, not durable stores.
Manual completion adjusts confidence (pass .95, needs_work .6, fail .4) without publishing. Audits compare skill/no-skill arms on one task; clean/self-edit/teacher passes use .95/.85/.8, and continued non-pass after retries becomes draft at .35. Initial unknown/inconclusive verdicts preserve publication state during per-skill finalization; this does not cover rewrite retries or later batch duplicate finalization. Per-skill auto-publish requires enabled owner policy/threshold and no necessity/utility/duplicate blocker. Batch duplicate finalization instead selects a processed passing keeper at or above the threshold, can publish it under the owner policy without reapplying those per-skill blockers, and bins other eligible processed duplicates; approval-required and unrelated skills remain protected from that batch mutation.
Approval validation stays in HTTP: pending job/id/owner/empty-session must match and the sealed grant is consumed with `allow_continuation=False` before continuation. Resumed skill/transcript context remains untrusted; unattended arms deny encountered approvals. Infrastructure unavailability stops a batch without a skill verdict or finalization. Existing limits remain: rewrite retry results other than pass, including unknown/inconclusive or an approval pause, can still lead to demotion, and manual cancellation may retain private continuation state. Extraction does not add model/containment availability or broad behavioral equivalence guarantees.
## Current Gaps
- `services/memory/service.py` needs an explicit owner-scope/support decision before it is treated as a public memory API.
- The agent level-0 skill index should thread owner or be documented as an intentional local/global index.
- MCP memory still needs a deliberate multi-user UX/config decision, but current behavior avoids cross-owner access when owner-bearing rows exist without an explicit MCP owner env.
- Memory JSON import does not rebuild vector indexes.
- Admin wipe vector clearing is currently ineffective.
+46 -15
View File
@@ -1,6 +1,6 @@
# Canonical Provider And Model Capability Layer
Last updated: dev@86b886c | 2026-10-14
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -15,9 +15,14 @@ This spec covers the implementation introduced on current `dev` in:
- regression coverage in `tests/test_model_capabilities.py` and
`tests/test_model_capability_readers.py`.
The layer normalizes already-fetched JSON-compatible values. It performs no network I/O, does not shape provider requests, does not persist its output, and does not authorize model or tool use. No production caller currently consumes the canonical records outside this package; runtime integration remains later work.
The layer normalizes already-fetched JSON-compatible values. It performs no
network I/O, does not shape provider requests, does not persist its output, and
does not authorize model or tool use. No production caller currently consumes
the canonical records outside this package; runtime integration remains later
work.
There is no `src/provider_capability_schemas.py`, capability-specific diagnostics module, or runtime model-quirk registry on current `dev`.
There is no `src/provider_capability_schemas.py`, capability-specific
diagnostics module, or runtime model-quirk registry on current `dev`.
## Layer Boundaries
@@ -42,7 +47,9 @@ There is no `src/provider_capability_schemas.py`, capability-specific diagnostic
capability object, assertions, deterministic controls, and optional raw
provider evidence.
Provider transport support and per-model support are separate facts. Request and response adapters remain in `src.llm_core` and related provider modules. Model-specific observations remain in [model-quirks.md](model-quirks.md).
Provider transport support and per-model support are separate facts. Request
and response adapters remain in `src.llm_core` and related provider modules.
Model-specific observations remain in [model-quirks.md](model-quirks.md).
## Current Serialized Shapes
@@ -63,13 +70,20 @@ Provider transport support and per-model support are separate facts. Request and
}
```
`ModelCapabilityRecord.to_dict()` wraps that value with `vendor`, `model_id`, `stable_model_id`, `display_name`, `capability_assertions`, and `deterministic_controls`. It does not currently emit a schema version or the flat `provider`/`model`/`features`/`controls` shape. Raw provider fields are included only when the caller passes `include_raw=True`.
`ModelCapabilityRecord.to_dict()` wraps that value with `vendor`, `model_id`,
`stable_model_id`, `display_name`, `capability_assertions`, and
`deterministic_controls`. It does not currently emit a schema version or the
flat `provider`/`model`/`features`/`controls` shape. Raw provider fields are
included only when the caller passes `include_raw=True`.
Endpoint configuration can explicitly map `model_type=llm` to chat and `model_type=image` to image generation. Missing or unrecognized endpoint types stay unknown rather than silently becoming chat-capable in this schema layer.
Endpoint configuration can explicitly map `model_type=llm` to chat and
`model_type=image` to image generation. Missing or unrecognized endpoint types
stay unknown rather than silently becoming chat-capable in this schema layer.
## Identity And Reader Dispatch
`records_from_payload()` selects a reader from an explicit `vendor`, or from `detect_vendor(base_url, endpoint_kind)` when no vendor is supplied.
`records_from_payload()` selects a reader from an explicit `vendor`, or from
`detect_vendor(base_url, endpoint_kind)` when no vendor is supplied.
Current detection order and behavior are:
@@ -80,7 +94,11 @@ Current detection order and behavior are:
These are normalization hints, not authorization. Hostname checks accept an exact domain or its dot-delimited subdomains after lowercasing and removing a trailing dot, so names such as `notopenai.com` do not match `openai.com`; local-port mappings remain intentionally covered by tests. Callers must not treat any result as proof of endpoint trust.
Implemented reader modules are `generic_openai`, `openai`, `openrouter`, `google`, `llamacpp`, `ollama`, and `lmstudio`. Anthropic, Hugging Face, SGLang, and vLLM have placeholder vendor IDs but currently dispatch through the generic identity-only reader. Other explicitly supplied vendor strings are also preserved while using that generic reader.
Implemented reader modules are `generic_openai`, `openai`, `openrouter`,
`google`, `llamacpp`, `ollama`, and `lmstudio`. Anthropic, Hugging Face,
SGLang, and vLLM have placeholder vendor IDs but currently dispatch through the
generic identity-only reader. Other explicitly supplied vendor strings are
also preserved while using that generic reader.
Stable model identity is scoped in this order:
@@ -90,9 +108,15 @@ Stable model identity is scoped in this order:
## Generic Identity-Only Contract
The generic reader accepts mapping payloads containing `data[]` or `models[]`. Each item must itself be a mapping and provide `id`, `name`, or `model`. Bare-list payloads and `key`/`slug`-only items are not accepted by the current implementation.
The generic reader accepts mapping payloads containing `data[]` or `models[]`.
Each item must itself be a mapping and provide `id`, `name`, or `model`.
Bare-list payloads and `key`/`slug`-only items are not accepted by the current
implementation.
The reader deliberately returns unknown family, modalities, capabilities, and controls. It preserves the raw item on the in-memory record but does not parse type/task fields, descriptions, ownership, supported-parameter lists, capability-looking booleans, or token limits.
The reader deliberately returns unknown family, modalities, capabilities, and
controls. It preserves the raw item on the in-memory record but does not parse
type/task fields, descriptions, ownership, supported-parameter lists,
capability-looking booleans, or token limits.
## Provider-Native Readers
@@ -112,13 +136,22 @@ The reader deliberately returns unknown family, modalities, capabilities, and co
served model. It records tool/streaming claims, explicit unsupported
vision/audio assertions, controls, and runtime/training/size limits.
Readers tolerate non-object entries and unknown fields where their helpers permit it. They do not infer authoritative capability from model IDs or display names.
Readers tolerate non-object entries and unknown fields where their helpers
permit it. They do not infer authoritative capability from model IDs or display
names.
## Evidence Semantics
The canonical vocabulary includes admin override, endpoint configuration, provider reader, Cookbook/Hugging Face, maintained registries, heuristic, probe, and unknown sources. It also defines explicit, provider-reported, registry, heuristic, and unknown confidence values.
The canonical vocabulary includes admin override, endpoint configuration,
provider reader, Cookbook/Hugging Face, maintained registries, heuristic,
probe, and unknown sources. It also defines explicit, provider-reported,
registry, heuristic, and unknown confidence values.
Those tokens make evidence representable; current `dev` does not implement a global precedence, merge, expiry, or conflict-resolution engine. Assertions generated by readers are usually `claimed`; a `CapabilityProbeResult` maps pass to verified, fail to unsupported, and partial to claimed at the scope carried by that object.
Those tokens make evidence representable; current `dev` does not implement a
global precedence, merge, expiry, or conflict-resolution engine. Assertions
generated by readers are usually `claimed`; a `CapabilityProbeResult` maps pass
to verified, fail to unsupported, and partial to claimed at the scope carried
by that object.
## Tests
@@ -132,8 +165,6 @@ Focused tests pin:
- OpenRouter, Google, Ollama, LM Studio, and llama.cpp mappings;
- negative cases that avoid name-based media/capability inference.
The provider detector has narrow production consumers in `src/ai_interaction.py` and `routes/gallery/gallery_routes.py` for selecting OpenRouter media request behavior. `ModelCapabilityRecord` normalization remains disconnected from runtime model discovery and request builders; helper reuse must not be described as full canonical integration.
## Current Gaps
- Canonical records are not yet used by runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers.
+1 -2
View File
@@ -1,6 +1,6 @@
# Provider Capability Specs
Last updated: dev@86b886c | 2026-10-14
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -54,7 +54,6 @@ Current reader dispatch does not infer a provider from payload shape. It uses an
- [github-copilot.md](github-copilot.md): account model-list observations and required runtime headers.
- [chatgpt-subscription.md](chatgpt-subscription.md): Codex model identity and Responses event shape.
- [cohere.md](cohere.md): native endpoint/catalog observations; not currently normalized.
- [featherless.md](featherless.md): plan-filtered catalog search and explicit picker pins without bulk discovery.
### Other provider identity and general/identity-only observations
+24 -21
View File
@@ -1,40 +1,43 @@
# ChatGPT Subscription Provider Shape
Last updated: dev@86b886c | 2026-10-15
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `chatgpt_subscription`; Codex Responses transport; auth and runtime code in `src/chatgpt_subscription.py`, `routes/chatgpt_subscription_routes.py`, and `src/llm_core.py`. There is no dedicated ChatGPT Subscription canonical reader on current `dev`.
Canonical provider ID `chatgpt_subscription`; Codex Responses transport;
auth and runtime code in `src/chatgpt_subscription.py`,
`routes/chatgpt_subscription_routes.py`, and `src/llm_core.py`.
There is no dedicated ChatGPT Subscription canonical reader on current `dev`.
## Catalog Shape
The account-scoped Codex models endpoint returns root `models[]`; `slug` is the request identity and `visibility`/`priority` control availability/order. These fields do not prove tools, reasoning, vision, or context. Null/malformed model lists fail soft rather than crashing discovery (#5280/#5281).
The account-scoped Codex models endpoint returns root `models[]`; `slug` is the
request identity and `visibility`/`priority` control availability/order. These
fields do not prove tools, reasoning, vision, or context. Null/malformed model
lists fail soft rather than crashing discovery (#5280/#5281).
The canonical generic reader does not accept `slug`-only items, so this runtime catalog is not currently normalized into `ModelCapabilityRecord` values.
The canonical generic reader does not accept `slug`-only items, so this runtime
catalog is not currently normalized into `ModelCapabilityRecord` values.
## Request And Response Shape
Transport uses an inference-only ChatGPT backend Responses payload allowlist: model, instructions, input, stream, store, optional temperature and reasoning effort. It drops native tools, tool choice, previous-response/background fields and unrelated options. `build_responses_input()` flattens conversation history into text input/output items; multipart image blocks are not preserved. Native tool calls/results and encrypted typed reasoning continuity are not preserved as a general protocol. Agent tools therefore use the text protocol on this provider rather than a claimed native function-call continuation.
Transport uses a ChatGPT backend Responses endpoint, `input` items, flattened
function tools, streamed function-call argument events, exact `call_id`, and
`function_call_output` continuation. Parallel calls and encrypted reasoning
continuity require preserving typed output/history rather than coercing all
roles to text. This shape is supported by the existing adapter and the focused
tool-calling follow-up evidence in #5490; unmerged observations remain claimed
until integrated/reproduced.
OAuth/device credentials and refresh are provider-session behavior. Expired credentials should return an actionable reconnect error, not generic model failure.
OAuth/device credentials and refresh are provider-session behavior. Expired
credentials should return an actionable reconnect error, not generic model
failure.
## Fallback And Safety
Only the explicit internal base/ChatGPT host selects this provider. Never send subscription credentials to a custom OpenAI-compatible URL. Catalog slugs alone remain identity-only. Runtime catalog metadata can retain advertised reasoning levels, but the runtime also supplies maintained defaults and model-name fallback metadata; those are not canonical verified capability cards.
## Account And Usage Ownership
Device-flow setup supports multiple accounts using stable provider-auth IDs; display labels are not identity. Reconnect, delete, inventory and account usage are owner-scoped. Subscription endpoint inventory applies exact effective-owner equality even for admins, and refresh deduplication includes the provider-auth ID to keep accounts separate.
`GET /api/chatgpt-subscription/accounts/{auth_id}/usage` returns normalized rate-limit telemetry for exactly one owned auth session. `src.chatgpt_subscription.UsageCache` keeps deep-copied normalized snapshots for 45 seconds, bounded to 256 auth-id entries with FIFO eviction rather than single-flight refresh, and ownership is checked before cache hits. Forced refresh replaces the snapshot; reconnect/deletion invalidates cached usage. Errors return credential-free unavailable payloads. `static/js/chatgptSubscriptionUsage.js` owns the frontend read/display flow.
Runtime catalog metadata retains supported/default reasoning levels and is exposed through `/api/models` model metadata. `validate_reasoning_effort()` omits unsupported overrides; its maintained defaults and name-based fallback are runtime compatibility rules rather than canonical provider evidence.
## Binding And Telemetry Limits
New account setup creates a distinct auth/endpoint pair. Sessions persist endpoint id so the bound endpoint wins over labels; removed or disabled bound endpoints fail closed rather than borrowing another login. Legacy unbound sessions can bind the oldest enabled owner-visible URL-matching endpoint before resolving its exact owner-scoped auth session. Runtime bearer credentials are attached request-locally only to the registered subscription route. Forked session account identity and usage cache ownership remain independent of chat approval grants.
Usage parsing bounds finite numeric telemetry, preserves missing values as unknown and shapes credential-free errors; a JWT claim can supply unverified routing metadata without becoming verified identity. Usage cache and optimistic UI expansion do not guarantee fresh quota or live provider acceptance. Session `effort:<level>` is validated on model changes/partial generation settings, and reasoning effort partitions inference cache identity; metadata defaults/name fallbacks remain compatibility evidence.
Only the explicit internal base/ChatGPT host selects this provider. Never send
subscription credentials to a custom OpenAI-compatible URL. Catalog slugs stay
identity-only unless account-scoped fields or probes supply capability.
## Current Gaps
-26
View File
@@ -1,26 +0,0 @@
# Featherless Provider Shape
Last updated: dev@86b886c | 2026-10-15
## Scope
Runtime provider ID `featherless`; OpenAI-compatible calls in `src/llm_core.py`, catalog and picker ownership in `routes/model_routes.py`, and setup/selection UI in `static/js/admin.js` and `static/js/providers.js`. No dedicated canonical capability reader exists.
## Catalog And Picker
Normal endpoint probing returns no bulk Featherless inventory. New rows use API endpoint kind and an explicit empty pin list; only selected model IDs become picker inventory. Admin `GET /api/model-endpoints/{ep_id}/catalog-search` queries `/v1/models` with `q`, `available_on_current_plan=true`, `status=active`, `conversational=true`, and bounded `page`/`per_page` (maximum 100). Its in-memory cache lasts 45 seconds and keys endpoint ID, normalized query and pagination. Upstream authorization, rate-limit and timeout failures become route errors rather than fabricated model cards.
Reachability tries `/v1/plan` and then a one-item plan-filtered model query. Endpoint status and selected model inventory are distinct facts; a reachable account need not have any pinned models.
## Request And Capability Boundaries
Calls use the OpenAI-compatible runtime adapter. Model availability on the current plan does not prove native tools, reasoning, modality, or context. Per-model endpoint tool modes are operator configuration; canonical generic normalization remains identity-only.
## Selection UI Limits
The admin picker debounces queries after two characters, pages bounded catalog results and saves pins/tool modes optimistically. Initial search cancellation does not establish equivalent load-more generation guards, and ignored PATCH response status does not prove accepted persistence. Lowercased query cache identity, explicit empty pins and endpoint reachability remain separate from provider plan/capability acceptance.
## Current Gaps
- No native canonical reader maps plan/catalog evidence into capability records.
- Catalog search is a live provider dependency; cache and pins do not guarantee continued account availability.
+19 -8
View File
@@ -1,14 +1,17 @@
# llama.cpp Provider Shape
Last updated: dev@86b886c | 2026-10-14
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages compatibility plus native server metadata; reader `src/model_capability_readers/llamacpp.py`.
Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages
compatibility plus native server metadata; reader
`src/model_capability_readers/llamacpp.py`.
## Metadata Shapes
`/v1/models` provides served identity and can include server model entries; native `/props` is authoritative for the running model/server combination:
`/v1/models` provides served identity and can include server model entries;
native `/props` is authoritative for the running model/server combination:
- `model_alias`/`model_path`;
- `default_generation_settings.n_ctx` and sampling `params`;
@@ -17,17 +20,25 @@ Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages c
- `modalities.vision|audio`;
- current server/build state.
Capability depends on weights, projection/model assets, chat template, parser, and launch flags. It is endpoint evidence, not a checkpoint-name claim. `/props` and `/v1/models` can be merged only for the same served identity.
Capability depends on weights, projection/model assets, chat template, parser,
and launch flags. It is endpoint evidence, not a checkpoint-name claim.
`/props` and `/v1/models` can be merged only for the same served identity.
## Request And Response Shape
llama-server supports several OpenAI-compatible tasks and native extensions. Do not infer embeddings/rerank/chat solely from the OpenAI model card; use an explicit server model capability field or endpoint configuration. Tool and reasoning correctness can depend on selected chat template and parser.
llama-server supports several OpenAI-compatible tasks and native extensions.
Do not infer embeddings/rerank/chat solely from the OpenAI model card; use an
explicit server model capability field or endpoint configuration. Tool and
reasoning correctness can depend on selected chat template and parser.
## Fallback And Safety
The registry selects llama.cpp through an explicit vendor or endpoint kind; it does not auto-detect `/props` from payload shape. Port 8000 currently maps to the vLLM placeholder, while 8080 falls through to generic OpenAI-compatible. llama.cpp-only `session_id` and `cache_prompt` affinity fields must remain local endpoint behavior and never leak to strict cloud providers (#4640 and current affinity tests).
Runtime context lookup in `src/model_context.py` tries `/slots` first and falls back to `/props.default_generation_settings.n_ctx`, covering servers launched without `--slots`. This runtime path remains separate from canonical reader merging.
The registry selects llama.cpp through an explicit vendor or endpoint kind; it
does not auto-detect `/props` from payload shape. Port 8000 currently maps to
the vLLM placeholder, while 8080 falls through to generic OpenAI-compatible.
llama.cpp-only `session_id` and `cache_prompt` affinity fields must remain local
endpoint behavior and never leak to strict cloud providers (#4640 and current
affinity tests).
## Current Gaps
+21 -8
View File
@@ -1,12 +1,19 @@
# Model Behavior Observations
Last updated: dev@86b886c | 2026-10-14
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
This file records model- or provider+model-specific behavior observed in Odysseus code, tests, Issues, PRs, commits, and provider documentation. It is a compact evidence map, not a runtime matcher. General canonical rules belong in [model-capability-canonical.md](model-capability-canonical.md); provider-wide transport belongs in [the provider map](model-providers/_readme.md).
This file records model- or provider+model-specific behavior observed in
Odysseus code, tests, Issues, PRs, commits, and provider documentation. It is a
compact evidence map, not a runtime matcher. General canonical rules belong in
[model-capability-canonical.md](model-capability-canonical.md); provider-wide
transport belongs in [the provider map](model-providers/_readme.md).
The canonical capability layer intentionally has no `src/model_behavior_quirks.py`. Adding a registry before runtime call sites carry structured provider, model, version, and dialect identity would create another model-name matching layer.
The canonical capability layer intentionally has no
`src/model_behavior_quirks.py`.
Adding a registry before runtime call sites carry structured provider, model,
version, and dialect identity would create another model-name matching layer.
## General Observation Template
@@ -20,7 +27,9 @@ Record only the fields supported by the evidence:
- source, confidence, status, and reproduction date;
- whether the behavior is already implemented in runtime code.
If exact structured identity is unavailable, keep the observation here and in its current tested runtime location. Do not promote it through substring, regex, prose, or serialized-prompt parsing in the canonical layer.
If exact structured identity is unavailable, keep the observation here and in
its current tested runtime location. Do not promote it through substring,
regex, prose, or serialized-prompt parsing in the canonical layer.
## Model-Specific Observation Map
@@ -34,7 +43,9 @@ If exact structured identity is unavailable, keep the observation here and in it
| Ollama native `gpt-oss` reasoning level | `gpt-oss` served through Ollama native | `think` accepts low/medium/high and does not represent off | provider docs; deployment scoped |
| Ollama compatibility disable observation | Ollama 0.20.6+, observed Qwen3.5 compatibility path | `reasoning_effort: none` was reported to disable reasoning | #5503; unmerged/low confidence until reproduced |
Issue and commit references are evidence identifiers, not runtime dependencies. Open or unmerged observations remain provisional until reproduced or supported by current provider documentation.
Issue and commit references are evidence identifiers, not runtime dependencies.
Open or unmerged observations remain provisional until reproduced or supported
by current provider documentation.
## Other Model-Level Observations
@@ -62,11 +73,13 @@ Issue and commit references are evidence identifiers, not runtime dependencies.
#1704, #1478). Native engine metadata or a verified endpoint probe outranks
a model-family name list.
Current `src.model_profiles` is a separate runtime schema/thinking profile helper: bounded `odysseus`/`ajax` tokens in the model ID leaf select compact schema and progressive thinking. It is not the absent canonical behavior registry. `src.utility_effort` validates utility effort against ChatGPT metadata that includes maintained/model-name defaults; callers must not describe those defaults as verified provider capability.
## Promotion Gate
Before an observation becomes canonical runtime behavior, a consumer must already have the necessary structured identity and tests must cover both its positive scope and a neighboring negative scope. Request control and response visibility remain separate: hiding reasoning text is not the same as disabling reasoning at the provider (#2905).
Before an observation becomes canonical runtime behavior, a consumer must
already have the necessary structured identity and tests must cover both its
positive scope and a neighboring negative scope. Request control and response
visibility remain separate: hiding reasoning text is not the same as disabling
reasoning at the provider (#2905).
## Current Gaps
+19 -24
View File
@@ -1,6 +1,6 @@
# Persistence
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -13,7 +13,8 @@ This spec covers durable state in:
- `core/models.py`;
- `core/session_manager.py`;
- `core/atomic_io.py`;
- `src/attachment_refs.py`, `src/upload_handler.py`, and `routes/upload_routes.py` for durable upload references and retention;
- `src/attachment_refs.py`, `src/upload_handler.py`, and
`routes/upload_routes.py` for durable upload references and retention;
- JSON stores managed by `core/auth.py`, `src/settings.py`, `src/api_key_manager.py`, `src/preset_manager.py`, `src/integrations.py`, `src/upload_handler.py`, `src/personal_docs.py`, `src/research_handler.py`, `src/bg_jobs.py`, `routes/prefs_routes.py`, canonical `routes/contacts/contacts_routes.py` and `routes/vault/vault_routes.py` plus their shims, `routes/cookbook_routes.py`, and memory/skills managers;
- canonical `routes/email/email_helpers.py` scheduled-email storage;
- `routes/backup_routes.py` and `scripts/odysseus-backup`;
@@ -23,9 +24,15 @@ This spec covers durable state in:
`core/database.py` owns SQLAlchemy models and startup migrations. `src/database.py` is a compatibility re-export for legacy imports. Route and service code commonly owns its own `SessionLocal()` lifecycle instead of using one central unit-of-work wrapper.
The default database is SQLite at `DATA_DIR/app.db`. `src.runtime_paths` and `src.constants` own the data-dir default: source runs use the repository `data/` directory, frozen builds default to `~/.odysseus/data`, and `ODYSSEUS_DATA_DIR` overrides both. The core requirements include `psycopg2-binary` for PostgreSQL driver availability. SQLAlchemy can point at a non-SQLite `DATABASE_URL`, but current startup migrations/backfills are SQLite-first and often use `sqlite3`, `PRAGMA`, or SQLite catalog queries. External DBs are not fully migration-compatible unless those helpers are made backend-neutral.
The default database is SQLite at `DATA_DIR/app.db`. `src.runtime_paths` and `src.constants` own the data-dir default: source runs use the repository `data/` directory, frozen builds default to `~/.odysseus/data`, and `ODYSSEUS_DATA_DIR` overrides both. SQLAlchemy can point at a non-SQLite `DATABASE_URL`, but current startup migrations/backfills are SQLite-first and often use `sqlite3`, `PRAGMA`, or SQLite catalog queries. External DBs are not fully migration-compatible unless those helpers are made backend-neutral.
After `Base.metadata.create_all()`, `init_db()` resolves file-backed SQLite paths from SQLAlchemy's parsed engine URL and attempts to restrict the main database plus existing `-journal`, `-wal`, and `-shm` sidecars to `0600` on POSIX. Driver-qualified, query-tagged, and local `file:` URI forms are covered; non-SQLite, in-memory SQLite, and Windows paths are skipped. A failed POSIX chmod is logged because the database and sidecars can contain password/token hashes and encrypted provider material.
After `Base.metadata.create_all()`, `init_db()` resolves file-backed SQLite
paths from SQLAlchemy's parsed engine URL and attempts to restrict the main
database plus existing `-journal`, `-wal`, and `-shm` sidecars to `0600` on
POSIX. Driver-qualified, query-tagged, and local `file:` URI forms are covered;
non-SQLite, in-memory SQLite, and Windows paths are skipped. A failed POSIX
chmod is logged because the database and sidecars can contain password/token
hashes and encrypted provider material.
Timestamp defaults use `utcnow_naive()` so existing naive `DateTime` columns stay UTC without the deprecated `datetime.utcnow()` default.
@@ -37,15 +44,17 @@ Current model families include:
- email accounts, model endpoints, MCP servers, comparisons;
- provider auth sessions for OAuth/device-flow-backed provider credentials;
- API tokens, admin-global webhooks, user tools/tool data, integrations;
- crew members, scheduled tasks, task runs, notification logs, background tool jobs, notes;
- memory rows, calendar calendars, calendar events, and email-calendar invitation revision/tombstone state.
- crew members, scheduled tasks, task runs, notes;
- memory rows, calendar calendars, and calendar events.
Chat persistence stores model-readable text plus compact attachment-reference lines in `chat_messages.content`, while structured references remain in message metadata. Provider data URLs used by the live turn are not duplicated into the durable transcript. The FTS migration recreates insert/update triggers to omit inline media and scrubs legacy indexed rows that still contain data URLs.
Chat persistence stores model-readable text plus compact attachment-reference
lines in `chat_messages.content`, while structured references remain in message
metadata. Provider data URLs used by the live turn are not duplicated into the
durable transcript. The FTS migration recreates insert/update triggers to omit
inline media and scrubs legacy indexed rows that still contain data URLs.
Current calendar/task persistence includes CalDAV remote identity columns (`CalendarCal.remote_href`, `CalendarCal.remote_etag`, `CalendarEvent.remote_href`, `CalendarEvent.remote_etag`), `CalendarEvent.caldav_sync_pending` for retryable writeback state, and `ScheduledTask.character_id` for built-in task persona selection.
`Note.gallery_id` links drawing notes to stable gallery images. `CalendarEvent` source-email identifiers preserve invitation provenance; `EmailCalendarInvitation` keeps owner/sender/source/recurrence revision and cancellation state for replay decisions.
`EmailAccount` includes encrypted password fields plus Google OAuth fields (`oauth_provider`, encrypted access/refresh tokens, token expiry) and optional `display_name`. Startup migrations add those OAuth/display columns idempotently for older databases.
Email default-account state is serialized per owner. Startup normalizes legacy duplicate defaults and installs a per-owner unique default constraint/index; create, delete/promotion, set-default, demo teardown, and user rename perform their default transition in one locked transaction. Multi-owner rename locks are acquired in canonical order, so a stale concurrent default mutation fails closed instead of recreating multiple defaults.
@@ -118,20 +127,6 @@ Docker bind-mounts `data/`, `logs/`, cache/local state, and optional Chroma stat
ChromaDB/vector stores are optional durable storage outside `data/app.db`; missing Chroma degrades RAG, memory-vector, and tool-index features without blocking core SQLite/JSON persistence. Vector collections can be lane-suffixed for custom HTTP embeddings versus FastEmbed fallback. See `documents-rag-uploads.md`.
## Run Evidence And New Durable State
`Session` adds `cwd`, endpoint id, memory-extraction/injection and skill-injection toggles, thinking mode, temperature/max-token overrides, and cumulative cost. `ModelEndpoint.model_tool_modes` stores per-model tool-schema preferences. `ScheduledTask.request_authority_json` stores the server-only admitted request snapshot. Startup migration helpers add these columns to legacy schemas. `BackgroundToolJob` holds origin owner/session/tool/query, status, payload/summary, and delivered message id; `NotificationLog` persists owner-attributed task completion/error notification text.
`src.agent_runtime.effect_log` stores append-only claims, outcomes, and observations in `DATA_DIR/effects/<root-run-id>.jsonl`, with launch-generation indexes alongside them. Claims are fsynced before backend invocation; persistence failure refuses execution. Reload appends interrupted outcomes for unsettled non-running claims, while eligible owned `RUNNING` process claims survive for background lifecycle settlement; it never manufactures success or fresh state. Appends merge durable history under an advisory lock before allocating positions. Cross-process locking and directory-fsync guarantees are POSIX-specific; other platforms have narrower guarantees. Claim files use restrictive creation, no-follow/regular-file/single-link checks, strict row validation and a tolerated torn final tail. Non-claim persistence can degrade to volatile evidence; durable claim failure still refuses invocation. Advisory-lock append merges durable rows, allocates positions and repairs only a torn tail; reader locking does not silently repair. Launch indexes use fsync/replace ordering, with narrower non-POSIX guarantees. These checks do not imply authenticated tamper-proof logs, constant-cost growth or identical index hardlink checks. This store is evidence, not operation authority.
`data/containment_grants.json` and `data/bg_jobs.json` preserve containment/process identity across restart. Browser producer state resolves through `BROWSER_RESOURCES_DIR` in shared constants. These server control-plane stores are denied to native model filesystem operations; ownership and runtime integrity must remain separate from editable user artifacts.
## Cleanup Counts And Chroma Volumes
`src/cleanup_service.py` derives preview and deletion eligibility from durable message rows, not the session counter. Recent-session protection uses a limited ID query; final DELETE rechecks owner/archive/age/importance/message threshold. DELETE RETURNING identifies cleanup-owned deletions where supported; legacy SQLite uses rowcount plus survivor queries, with estimated space attribution bounded to the deleted count. Session-cache eviction occurs after commit.
The standard, AMD and NVIDIA Compose files mount `chromadb-data` at `/data`; native Chroma uses `data/chroma/`. Pre-fix Docker stores may have live data in the old container writable layer despite an empty volume at `/chroma/chroma`; recreation can destroy that layer. `docs/chromadb-volume-migration.md` documents cold rescue, image preservation, empty-volume seeding and staging/count verification before cutover. This is an operator procedure, not startup migration or proof of data recovery.
## Current Gaps
- Migration behavior is centralized but long and manual.
@@ -139,4 +134,4 @@ The standard, AMD and NVIDIA Compose files mount `chromadb-data` at `/data`; nat
- Some JSON store shapes are only documented by manager code and tests.
- Startup migrations lack a legacy-schema/idempotence test harness for owner backfills, encrypted-secret rewrites, and repeated runs.
- JSON-store atomicity is inconsistent across stores, though shared atomic writers, upload metadata recovery, prefs, and strict memory mutations now have focused coverage.
- New local stores require explicit ownership, backup, and control-plane access decisions; a filesystem root alone does not grant access to server state.
- Agent filesystem tools currently allow broad `data/` access; secret-bearing files under `data/` need explicit deny coverage.
+11 -11
View File
@@ -1,6 +1,6 @@
# Research
Last updated: dev@86b886c | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -50,8 +50,8 @@ Agent tools and the CLI read and mutate persisted research JSON directly. They a
`src.deep_research.DeepResearcher` owns multi-round research work:
- date/context setup;
- search provider selection and fallback through `src.research_navigator.ResearchNavigator`, which dispatches the shared provider chain;
- URL/content fetching through `ResearchNavigator.fetch()` and the canonical service content fetcher, with structured unavailable results for disabled private-browser reads;
- search provider selection and fallback through `src.search.providers` and `src.search.core`;
- URL/content fetching through `src.search.fetch_webpage_content`;
- separate tracking of analyzed URLs, last search errors, and empty-round limits;
- source summarization/extraction;
- synthesis into final answers/reports;
@@ -80,7 +80,12 @@ Spinoff/Discuss creates a new chat session from a saved report. It seeds the rep
Research persistence uses `data/deep_research/<session_id>.json`. Current JSON can include result/report text, raw report, sources, raw findings, stats, category, archived state, hidden images, owner, timestamps, and consumed state.
Route access to persisted report files is path-confined. Browser routes validate session ids against `^[a-zA-Z0-9-]{1,128}$`, enumerate trusted `*.json` files under the resolved research storage root, match by exact filename, reject symlink/path escapes after `resolve().relative_to(root)`, and then perform owner checks before detail/archive/delete/result-peek/spinoff reads or mutations. Invalid ids return 400; missing or cross-owner reports return 404.
Route access to persisted report files is path-confined. Browser routes validate
session ids against `^[a-zA-Z0-9-]{1,128}$`, enumerate trusted `*.json` files
under the resolved research storage root, match by exact filename, reject
symlink/path escapes after `resolve().relative_to(root)`, and then perform owner
checks before detail/archive/delete/result-peek/spinoff reads or mutations.
Invalid ids return 400; missing or cross-owner reports return 404.
`src.visual_report` owns HTML report generation from markdown-like research output, heading/TOC processing, category styling, image injection, allowlist sanitization of untrusted rendered HTML, and client-side controls for hiding images and discussing reports.
@@ -114,7 +119,8 @@ The active FastAPI app path uses `src.research_handler.ResearchHandler`.
`services/research/service.py` is a public wrapper around a duplicate `services.research.research_handler.ResearchHandler`. That services handler remains compatibility/cleanup surface rather than canonical runtime truth; check parity before assuming it has every active-route field or policy behavior.
Its source extraction skips non-dict finding rows so one malformed cached or generated entry does not discard later valid URL/title/summary sources.
Its source extraction skips non-dict finding rows so one malformed cached or
generated entry does not discard later valid URL/title/summary sources.
Search compatibility also matters: `src.search.core`, `src.search.providers`, and `src.search.content` alias the service search path so old imports stay live without a second fetch implementation.
@@ -139,12 +145,6 @@ Existing useful coverage includes deep-research runtime/degraded tests, handler/
Coverage is still thin around live job route ownership, `/api/research/start` route behavior, SSE/result-peek/cancel edges, spinoff endpoint ownership, tool/CLI direct JSON access, remote-image policy, and frontend panel/jobs behavior.
## Bounded Navigation And Evidence
`src.research_navigator.py` owns parsed `ResearchAction` requests, normalized `ResearchPage` responses, and source assessments. Search uses the shared provider chain off the event loop; ordinary fetch uses the canonical URL-safe content fetcher, while `browser_read` requests the private browser tool and currently receives the common denial for page actions. `DeepResearcher` chooses bounded navigation actions, records retrieval mode/provider use and source coverage, and can request a browser retry for weak fetches; the current disabled page-action policy prevents that retry from supplying page evidence.
Small local model heuristics select a simpler fact/research path with smaller planning calls. Findings-window synthesis and extraction concurrency/timeouts still bound work; useful source text remains a dependency; private-browser page actions are currently unavailable. Scheduled research binds sealed task authority around the handler call; direct handler/route behavior must not be assumed to share that scheduling wrapper. Saved reports retain gathered evidence and fallback reports when model synthesis cannot complete.
## Current Gaps
- Consolidate, retire, or clearly deprecate `services/research/research_handler.py`.
+14 -21
View File
@@ -1,6 +1,6 @@
# Runtime
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -13,10 +13,10 @@ This spec covers current app runtime wiring in:
- `core/constants.py`;
- `src/constants.py`;
- `src/interactive_gate.py`;
- `src/process_reaper.py`, `src/process_lifecycle.py`, and `src/process_ownership.py`;
- `src/host_docker_access.py`;
- `core/middleware.py`;
- all route setup functions registered from `app.py`, including canonical `routes/admin_wipe/`, `routes/cleanup/`, `routes/compare/`, `routes/contacts/`, `routes/document/`, `routes/gallery/`, `routes/history/`, `routes/mcp/`, `routes/memory/`, `routes/note/`, `routes/research/`, `routes/search/`, `routes/task/`, `routes/vault/`, and `routes/webhook/` packages plus top-level compatibility shims;
- all route setup functions registered from `app.py`, including canonical
`routes/admin_wipe/`, `routes/cleanup/`, `routes/compare/`, `routes/contacts/`, `routes/document/`, `routes/gallery/`, `routes/history/`, `routes/mcp/`, `routes/memory/`, `routes/note/`, `routes/research/`, `routes/search/`, `routes/task/`, `routes/vault/`, and `routes/webhook/` packages plus top-level compatibility shims;
- `routes/prefs_routes.py`, `routes/workspace_routes.py`, and `companion/routes.py`;
- `src/generated_images.py` for generated-media file resolution;
- `launcher.py`, `Odysseus.spec`, and platform launcher scripts where frozen/native startup changes runtime paths;
@@ -30,7 +30,9 @@ This spec covers current app runtime wiring in:
`app.py` separately owns runtime singletons and integration hooks for auth, vector RAG, TTS/STT, webhooks, scheduled tasks, MCP, assistant log globals, event bus wiring, AI interaction globals, API-token cache invalidation, and foreground activity tracking. `src.runtime_paths` owns source-versus-frozen app/data path resolution; `src.constants` derives `DATA_DIR` from `ODYSSEUS_DATA_DIR` or that runtime default. `core/constants.py` and `src/constants.py` are both live import paths and are not fully identical today, so new constants need explicit placement/compatibility decisions.
The shared upload handler is also installed on the session manager and tool helper, and `app.py` injects it into attachment-bearing route factories so durable writers and cleanup use one lifecycle owner.
The shared upload handler is also installed on the session manager and tool
helper, and `app.py` injects it into attachment-bearing route factories so
durable writers and cleanup use one lifecycle owner.
## Routes And Static Serving
@@ -44,9 +46,9 @@ Admin wipe, cleanup, compare, contacts, documents, gallery, history, MCP, memory
The SPA routes `/`, `/notes`, `/calendar`, `/cookbook`, `/email`, `/memory`, `/gallery`, `/tasks`, and `/library` all serve `static/index.html`. `static/` is served with revalidation for `.js`, `.css`, and `.html` because the frontend ships raw browser modules with no hashed build output.
Direct app-owned endpoints include `/api/generated-image/{filename}`, `/backgrounds`, `/login`, `/api/version`, `/api/health`, `/api/ready`, `/api/runtime`, and `/api/activity/heartbeat`. The `/backgrounds` prototype route points at the absent `static/backgrounds.html` and is auth-gated when authentication is enabled despite its handler docstring describing it as public. This remains known route/static drift at the checked revision.
Direct app-owned endpoints include `/api/generated-image/{filename}`, `/backgrounds`, `/login`, `/api/version`, `/api/health`, `/api/ready`, `/api/runtime`, and `/api/activity/heartbeat`. `/backgrounds` points at `static/backgrounds.html`; if that file is absent or the route remains auth-gated, that is route/static drift rather than an intentional public contract.
`/static/*` is auth-exempt and public. SPA HTML routes are auth-gated except `/login`, and they are nonce-injected dynamic `HTMLResponse` values outside the static mount. Generated images and videos are served from `data/generated_images` through the generated-image resolver with private, no-cache, must-revalidate/nosniff headers; access checks precede disk probing and database verification errors return 503 (see `gallery-editor-media.md`).
`/static/*` is auth-exempt and public. SPA HTML routes are auth-gated except `/login`, and they are nonce-injected dynamic `HTMLResponse` values outside the static mount. Generated images and videos are served from `data/generated_images` through the generated-image resolver with immutable/nosniff caching.
## Runtime Security Boundaries
@@ -56,7 +58,7 @@ Security headers include HSTS and a restrictive `Permissions-Policy` that disabl
`_TIMEOUT_EXEMPT_PREFIXES` owns hard-timeout bypass policy. It is prefix-based and currently exempts all subroutes under `/api/chat`, `/api/shell/stream`, `/api/research`, `/api/model/download`, `/api/model/probe`, `/api/model-endpoints`, `/api/cookbook/setup`, `/api/upload`, `/api/image`, and `/api/memory/audit`. Memory audit has its own longer inactivity timeout.
Generated-image serving validates filename syntax before identity and access checks, then resolves the confined path and verifies file existence. With auth enabled, missing identity returns 401; recent registration, gallery ownership, linked-session ownership or an owned-chat metadata reference supplies the implemented access criterion. Missing or mismatched access returns 404, and database verification failures return 503. Explicit disabled-auth mode skips ownership verification. See `gallery-editor-media.md` for branch ordering and registry lifetime, and `auth-security.md` for `LOCALHOST_BYPASS`, internal-tool loopback, proxy-header exclusion, and owner-impersonation policy.
Generated-image path resolution fails closed for invalid names, path escape, and missing files. Ownership checks are best-effort when a current user exists: gallery rows owned by a different user return 404, rowless generated files are allowed, and DB/helper failures fail open. See `auth-security.md` for `LOCALHOST_BYPASS`, internal-tool loopback, proxy-header exclusion, and owner-impersonation policy.
## Runtime Behavior
@@ -68,11 +70,12 @@ Generated-image serving validates filename syntax before identity and access che
## Lifespan Startup
Upload cleanup first snapshots durable chat, document, gallery, note, and calendar references and aborts on scan or upload-index integrity failure.
Upload cleanup first snapshots durable chat, document, gallery, note, and
calendar references and aborts on scan or upload-index integrity failure.
Startup purges leftover incognito sessions, reconciles default scheduled tasks before the task runner starts, and backfills legacy skill owners when possible.
Startup fire-and-forget work includes upload cleanup, background-job monitoring, MCP built-in registration and user-server connection, tool-index warmup, model-endpoint warmup, endpoint keepalive, Cookbook serve lifecycle monitoring, and hourly null-owner sweeps. Skills Audit is scheduled per owner by `TaskScheduler`; app lifespan deliberately does not start a second ownerless audit. The in-process task scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`; email polling is started from email route setup and gated separately by `ODYSSEUS_INPROCESS_POLLERS`. Foreground-gate knobs are `BACKGROUND_TASK_FOREGROUND_GATE`, `BACKGROUND_TASK_QUIET_MS`, `BACKGROUND_TASK_MAX_WAIT_SECONDS`, and `BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS`.
Startup fire-and-forget work includes upload cleanup, background-job monitoring, MCP built-in registration and user-server connection, tool-index warmup, model-endpoint warmup, endpoint keepalive, Cookbook serve lifecycle monitoring, hourly null-owner sweeps, and nightly skill audit. The in-process task scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`; email polling is started from email route setup and gated separately by `ODYSSEUS_INPROCESS_POLLERS`. Foreground-gate knobs are `BACKGROUND_TASK_FOREGROUND_GATE`, `BACKGROUND_TASK_QUIET_MS`, `BACKGROUND_TASK_MAX_WAIT_SECONDS`, and `BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS`.
Shutdown cancels upload cleanup, stops the task scheduler, closes the webhook manager, and disconnects MCP servers.
@@ -87,20 +90,10 @@ Shutdown cancels upload cleanup, stops the task scheduler, closes the webhook ma
- `src.host_docker_access` treats host Docker access from inside the container as opt-in. Default Compose does not mount `/var/run/docker.sock`; `docker/host-docker.yml` plus `ODYSSEUS_ENABLE_HOST_DOCKER=true` are required before local container code considers the host Docker daemon available.
- Chroma-backed consumers degrade independently: personal-doc RAG can return route-level 503s, semantic memory vectors can be dropped from chat/memory wiring, and the tool index can fall back when vector retrieval is unavailable.
- RAG startup failure is throttled so failed clients do not poison later retries.
- MCP startup is asynchronous and non-critical. User-server connection is bounded, failures surface through MCP status routes, and unbound builtin MCP calls can reconnect after crashes; incarnation-bound calls return failure rather than silently replacing the backend.
- `/api/health` is liveness only. `src.readiness.check_readiness()` reports database reachability, writable data dir, informational local-first storage metadata and tool-index state. Database/data-dir checks gate `/api/ready`; `ODYSSEUS_REQUIRE_TOOL_INDEX_READY` additionally makes tool-index readiness critical when enabled. Default readiness does not certify RAG, Chroma, MCP, memory vectors or endpoint warmups.
- MCP startup is asynchronous and non-critical. User-server connection is bounded, failures surface through MCP status routes, and builtin MCP calls can reconnect after crashes.
- `/api/health` is liveness only. `/api/ready` checks database reachability, writable data dir, and local-first storage metadata; it does not prove optional subsystem health for RAG, Chroma, MCP, memory vectors, tool index, or endpoint warmups.
- `/api/diagnostics/services` is an admin diagnostics endpoint for optional service health. It reports bounded, non-intrusive checks for ChromaDB, SearXNG, email accounts, ntfy, and model provider endpoints with `ok`/`degraded`/`down`/`disabled` style status values and strips secret-bearing URLs/errors. `/api/diagnostics/logs` returns a bounded tail of the app log for admin troubleshooting.
## Startup Process Reconciliation
Lifespan calls `src.process_reaper` once to reconcile persisted containment grants and detached job identities from previous server runs. Verified orphan processes under abandoned containment grants are torn down; owned detached jobs intentionally survive restart. Unverifiable containment grants remain active and unsignalled, while `src.bg_jobs.disown_unverified()` marks foreign/unverifiable running job records failed without signalling and preserves pending follow-up. Retiring a record does not establish process death. `src.process_lifecycle` owns identity checks, TERM/KILL escalation, and observed-death receipts; caller-specific containment/browser/Cookbook policy remains outside it.
## Process And Service-Home Limits
Native launch readiness is a bounded functional containment probe plus exact supervisor/worker acknowledgement, not executable presence. Startup reconciliation retains unverified ownership and running detached job generations instead of fabricating death or renewed permission. Background delivery revalidates linkage after asynchronous continuation and retires exact observed-dead launch records; invalidated jobs do not prevent registry cleanup during shutdown. Missing procfs degrades discovery/command-line evidence and does not authorize signaling.
`core.platform_compat.service_home()` uses Windows `Path.home()` without importing `pwd`. Successful POSIX real-UID passwd lookup ignores a child HOME override; a missing passwd entry or lookup OS error falls back to `Path.home()`, which can honor that override. Model-visible runtime/context metadata is bounded and untrusted; environment-key allowlists do not scrub explicitly supplied values. Readiness errors expose bounded error categories rather than raw exception details, and readiness still does not certify optional provider/browser/model health.
## Current Gaps
- `app.py` is still a large route registry and runtime orchestrator. There is no generated route manifest or smaller runtime composition layer yet.
+6 -20
View File
@@ -1,6 +1,6 @@
# Search
Last updated: dev@86b886c | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -64,11 +64,13 @@ Runtime behavior:
- rejection of localhost, metadata, private, reserved, multicast, and link-local targets;
- redirect revalidation on each hop;
- one-time public DNS resolution per hop plus an `httpcore`/`httpx` pinned
transport that connects to the validated public IP while preserving the original URL, Host header, and TLS SNI, closing DNS-rebinding time-of-check drift;
transport that connects to the validated public IP while preserving the
original URL, Host header, and TLS SNI, closing DNS-rebinding time-of-check
drift;
- metadata, Open Graph image, list, table, code block, PDF, and text extraction;
- readable text extraction for `text/*`, Markdown, `.txt`, `.json`, `.jsonl`, and JSON content types;
- central User-Agent behavior through `WEB_FETCH_USER_AGENT`;
- soft and hard download byte caps through `WEB_FETCH_SOFT_MAX_BYTES` and `WEB_FETCH_HARD_MAX_BYTES`, with declared-length and streaming-budget checks; decoded response bytes are capped even for compressed transfer encodings, and truncation is reported;
- soft and hard download byte caps through `WEB_FETCH_SOFT_MAX_BYTES` and `WEB_FETCH_HARD_MAX_BYTES`, with declared-length and streaming-budget checks; requests prefer identity transfer encoding so compressed bodies cannot bypass the effective body cap;
- JS-heavy empty result hints;
- cache writes;
- empty/error result shape, including explicit HTTP-status failures instead of raising through callers.
@@ -122,28 +124,12 @@ Deep research wraps fetched webpage content through `untrusted_context_message("
## Optional And Platform Behavior
`ddgs` is optional; provider code has an HTML fallback. Search cache and analytics state live under the shared data dir and mkdir failures in read-only image layers are tolerated where possible. PDF extraction prefers pypdf layout text with a plain-text compatibility fallback and page labels, then optional pdfminer; a truncated soft-budget PDF can retry within the hard cap when its declared size allows. Native SearXNG defaults to `http://localhost:8080`; Docker uses the compose `searxng` service URL and pins the SearXNG image with a healthcheck.
`ddgs` is optional; provider code has an HTML fallback. Search cache and analytics state live under the shared data dir and mkdir failures in read-only image layers are tolerated where possible. PDF extraction uses `pdfminer.six` only when installed. Native SearXNG defaults to `http://localhost:8080`; Docker uses the compose `searxng` service URL and pins the SearXNG image with a healthcheck.
Compose preserves retained SearXNG settings but runs `scripts/migrate_searxng_settings.py` before startup to add missing `use_default_settings: true` inheritance. The migration accepts only a regular single-document YAML mapping, preserves BOM/newline/style/ownership/mode, writes and directory-fsyncs atomically, and no-ops when the key exists. Compose treats migration failure as non-fatal so SearXNG health reports the retained-file problem instead of the wrapper command preventing startup.
`httpx` and BeautifulSoup are required runtime dependencies for the active search/fetch path.
## Query Repair And Extractive Observations
`services/search/core.py` normalizes conversational provider queries, filters low-relevance results, respects explicit site scope, retries bounded broadened queries after empty results, and reapplies relevance/ranking on cache hits. Completed empty provider responses advance through the chain instead of repeating the same request as if it were a transport error. Named scholarly work lookup can use arXiv/OpenAlex metadata before generic search, and exact arXiv identifiers have a direct source path.
`src/search_intent.py` distinguishes requested publication windows from current reference information: current documentation/configuration alone does not imply recently published pages. `src/search_passages.py` owns literal, query-relevant excerpts and balanced bounded search observations across fetched sources, preserving explicit omission markers and source headers rather than prefix-truncating away later sources. These are extractive observations, not generated source claims. Deep research shares provider primitives through its navigator but retains its own result and failure shapes.
## Retrieval Limits And Source Selection
When SearXNG is primary and `searxng_yep` is absent from the configured chain, `_build_provider_chain()` inserts that separate candidate at index one, including when SearXNG is the only configured provider. An already configured YEP position is retained; other primary providers do not get the automatic insertion. Missing keyed providers are excluded. Exact-empty query relaxation is bounded and disabled for explicit operators, quotes or exclusions. Low-relevance filtering can retain scoped results rather than invent a confident match. Temporal qualifiers can exclude a year from product-number overlap checks, while genuine numeric model names remain significant.
News category and publication window are independent: current events infer a day and news a week where no explicit window exists, while current reference documentation does not imply recent publication. Software release hints can retain general category; explicit windows survive JSON-to-HTML and news-to-general fallback along with language/engines/SafeSearch. The optional DuckDuckGo HTML request has a maximum five-second request timeout, not a bound on the complete multi-provider search.
HTML extraction removes chrome from a parsed copy, prefers semantic main content and a sole substantial article (at least 200 stripped characters), and avoids ancestor duplication; multiple article cards retain main context. Thin-body and access-challenge rules are heuristics. An exact challenge title with corroborating short body is an unavailable page even under HTTP200 and is not cached as article evidence. XML/Atom preserves bounded raw text; failed arXiv category/date listings can return a suggested start-zero/max100 Atom URL without fetching it or claiming completeness. Extractor caches use the current semantic-links namespace rather than old parser results.
Scholarly metadata uses a versioned User-Agent, a 12-second lookup timeout and shared 20-second monotonic budget, with at most three manually checked redirects and cleared original query parameters after redirect. This helper checks destinations but uses ordinary HTTP transport rather than claiming the separate outbound-fetch DNS connection pinning contract. Title/identifier matching remains heuristic metadata lookup, not independent verification of a paper. Direct browser-source citations and search snippets remain untrusted and insufficient to certify full-report evidence.
## Current Gaps
- Search route handlers need direct tests for request body formats, provider validation, provider availability, and route error/empty-result shapes.
+12 -31
View File
@@ -1,6 +1,6 @@
# Settings And Admin Surfaces
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -20,7 +20,7 @@ This spec covers settings and admin surfaces in:
- `routes/model_routes.py` for `/api/tools` and settings-bound model endpoint references;
- `src/agent_tools/admin_tools.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_index.py` for `manage_settings`;
- `src/agent_loop.py` for stale agent prompt references to settings APIs;
- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn,agent,api,imageModels,speech,writingStyle}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`;
- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`;
- CLI helpers `scripts/odysseus-preset` and `scripts/odysseus-theme`.
Generic API integrations are cross-referenced in `integrations.md`. Model endpoint CRUD and endpoint cleanup are covered in `llm-models.md`. Email/contact/calendar legacy setting fallbacks stay with their domain specs.
@@ -54,10 +54,10 @@ Other active stores include:
Settings runtime:
- `GET /api/auth/features` is public feature visibility metadata;
- `POST /api/auth/features` requires admin unless auth is explicitly disabled;
- `GET /api/auth/settings` returns full settings to admins and the disabled-auth operator;
- with auth enabled, non-admin or unauthenticated `GET /api/auth/settings` returns `scrub_settings()` output;
- `POST /api/auth/settings` requires admin unless auth is explicitly disabled and only writes keys present in `DEFAULT_SETTINGS`.
- `POST /api/auth/features` is admin-only;
- `GET /api/auth/settings` returns full settings to admins;
- non-admin or unauthenticated `GET /api/auth/settings` returns `scrub_settings()` output;
- `POST /api/auth/settings` is admin-only and only writes keys present in `DEFAULT_SETTINGS`.
`src.settings_scrub` owns deep secret-key scrubbing for non-admin settings reads, including snake_case and camelCase secret-like key names. It preserves structure while blanking secret-shaped string values.
@@ -75,7 +75,7 @@ Admin gates inherit the auth contracts in `auth-security.md`: normal deployments
- bundled accessibility font selection such as OpenDyslexic and text-size variable application;
- CSS variable application.
`static/js/settings.js` coordinates compatibility exports and delegates Agent, image-model, speech and writing-style panel initialization to their named leaf modules; while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state.
`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state.
Settings panels cover provider/model/search/research/reminder/email/CalDAV/CardDAV/vault, accessibility/font/text-size, scoped tokens, and unified integrations. The hidden legacy fallback editor was removed; no current Settings panel exposes the new foreground fallback keys, so opt-in exists only through owner-scoped preferences/internal callers until a deliberate UI is added. Email OAuth connect preserves the selected SMTP security mode and returns to the Settings surface after callback. `static/js/admin.js` owns user/admin panels, model endpoints, builtin tool toggles, MCP forms, feature toggles, token/webhook panels, diagnostics, backup/import, and danger-zone wipes.
@@ -95,14 +95,6 @@ Runtime behavior:
`scripts/odysseus-preset` is a local CLI for preset store maintenance and backup of `presets.json`.
Custom presets now carry optional persona continuity notes, a `general`/`health` memory schema, `thinking_mode` (`on`, `off`, or provider default), and `show_persona_name`. `PresetManager.update_persona_memory()` updates matching active and saved personas; these remain shared preset data. The default custom output limit is 32,768 tokens.
## Writing Style And Admin Controls
`document_writing_style` and `email_writing_style` are separate global settings. `POST /api/auth/settings/document-style/extract` requires an admin unless auth is explicitly disabled. It reads a bounded document upload, extracts prose, and calls the owner-resolved Utility model (Default Chat if unavailable) with at most 30,000 sample characters. Its prompt treats the sample as untrusted data; the response is a proposed style string, and the temporary file is removed in `finally`. `static/js/settings/writingStyle.js` owns the UI. This is a model inference path rather than a deterministic style parser.
`auto_compact_threshold_percent` defaults to 85 and settings writes clamp it to 50–95. `utility_reasoning_effort` is an allowed per-user override; utility fallback entries can specify their own effort. Admin `PUT /api/auth/users/{username}/password` delegates to `AuthManager.reset_user_password`, accepts only existing non-admin targets, revokes their sessions after reset, and validates minimum length and the 72-byte UTF-8 limit.
## Tools Settings
`routes.model_routes` owns `/api/tools`, which writes `settings.json:disabled_tools` for global builtin tool toggles.
@@ -158,8 +150,8 @@ Vault tool paths duplicate some route behavior and can return vault item secrets
## Security And Provenance
- With auth enabled, non-admin and unauthenticated settings reads are scrubbed; explicit disabled-auth operator reads return full settings.
- Full settings responses, admin edit forms, vault flows, backup files, and local CLI artifacts can contain secrets and need their applicable access and storage protections.
- Non-admin and unauthenticated settings reads are scrubbed.
- Admin settings reads, admin edit forms, vault flows, backup files, and local CLI artifacts can contain secrets and must remain admin-only or locally protected.
- Backup artifacts are sensitive because settings may include API keys, passwords, tokens, and endpoint credentials.
- Diagnostics and logs should avoid adding secret-bearing values.
- Admin wipe is global per kind and crosses owners.
@@ -179,25 +171,14 @@ Vault tool paths duplicate some route behavior and can return vault item secrets
- Theme/preset frontend helpers tolerate malformed localStorage values.
- CLI helpers are local maintenance surfaces and may bypass HTTP route policy.
## App Configuration Gates And Cleanup
`routes/auth_routes.py` uses `_require_admin_user()` for settings writes, feature writes, document-style extraction and integration management. Explicit `AUTH_ENABLED=false` admits the operator without a session identity, and settings GET returns unscrubbed values in that mode; auth-enabled callers need an admin for writes and get scrubbed reads unless admin. User-store administration still requires a real admin identity. `static/js/settings.js` reports shortcut save failure with a toast; settings writes use the existing response-status-checking API helper.
`src/cleanup_service.py` previews and deletes using durable `ChatMessage` row counts rather than `Session.message_count`; it selects only the configured most-recent IDs for protection. The DELETE rechecks owner, archive state, age, importance and actual message threshold. Supported databases use DELETE RETURNING to attribute deleted rows and estimated space; legacy SQLite has a rowcount/survivor fallback with bounded estimate attribution. Cache eviction follows commit and includes observed externally removed candidates; estimates are not a measurement of reclaimed database bytes.
## Testing Notes
Current targeted coverage includes settings store fallback/error paths, settings scrub, shared frontend config caching/invalidation/prefetch behavior, prefs no-clobber behavior, atomic preset store/migration/CLI/localStorage helpers, backup import cross-user dedup, backup CLI restore/list-race safety, cleanup owner scope, diagnostics admin-gate/source/service-health/log-tail checks, admin promote/demote, admin wipe gallery, font family derivation, theme helper behavior, vault password-not-in-argv checks, setup/auth regressions, reserved usernames, Google email OAuth route/helper behavior, and a token-budget `manage_settings` path.
## Panel Save And Vault Identity Details
`static/js/settings/api.js` centralizes settings POSTs, rejects non-OK/403 responses and invalidates the shared cache in finally even on failure. Older per-panel unchecked-response behavior is superseded by this final helper; individual UI completion is not model/provider acceptance. Image model selectors retain the final mixed eligibility filter rather than an image-only historical rule. Sidebar build information fetches public version/build/source-commit metadata, hides failed/unknown responses and abbreviates hexadecimal commits for display; arbitrary overrides are not byte attestation.
Vault tool bindings require an explicit matching configuration owner and pin backend/config/session incarnations plus exact observed item identities. `routes/vault/vault_routes.py` checks configuration ownership on save and clears session/unlock time when adopting ownerless legacy state. Same-owner configuration edits retain saved session/unlock fields while invalidating bindings whose configuration incarnation changes. Configuration reads and login/unlock/lock/logout use common `require_admin()` without that owner-binding check. Observed catalog names or selectors cannot renew a revoked tool backend; search observations can update selected entries without proving every remote account/session is fresh.
## Current Gaps
- Settings/features route tests cover disabled-auth operation, auth-enabled read scrubbing, admin/non-admin writes, numeric validation and ignoring unknown keys; real auth-middleware integration and broader store-failure combinations still need coverage.
- Add route tests for `/api/auth/settings`: anonymous/non-admin scrubbed reads, admin full reads, non-admin POST rejection, and unknown-key ignore behavior.
- Add route tests for `/api/auth/features` admin writes.
- Add `/api/tools` and `manage_settings` tests for secret write refusal, enum/integer coercion failures, structured-setting refusal, reset/default behavior, endpoint/model resolution, and tool enable/disable aliases.
- Add backup tests for secret-bearing export policy, owner-scoped exported sections, invalid import handling, skills dedup, settings/features merge, and admin gates.
- Add diagnostics tests for broader error redaction and sensitive output limits.
@@ -206,4 +187,4 @@ Vault tool bindings require an explicit matching configuration owner and pin bac
- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests, and the real-ESM coordinator smoke in `tests/helpers/test_settings_shell_coordinator.mjs` additionally covers the shell's admin-only visibility, admin-controller handoff and Peek fade/clear behavior.
- Decide whether `user_templates` and `group_presets` should remain shared despite user-facing names.
- Decide whether backup/import should preserve explicit owner fields or force imported owner ownership.
- Settings panel code now has additional domain modules, but `static/js/settings.js` and `static/js/admin.js` remain substantial coordinators; future extraction must preserve registry and lifecycle ownership.
- Continue decomposing the still-large `static/js/settings.js` (5,583 lines) and `static/js/admin.js` (4,122 lines) without duplicating registry ownership. The shell and navigation concerns now live in `static/js/settings/`; what remains in `settings.js` is panel code, and `initUnifiedIntegrations()` alone is ~2,140 lines of it. `admin.js` has no extracted shell of its own yet.
+31 -55
View File
@@ -1,6 +1,6 @@
# Shell And MCP
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
@@ -9,8 +9,6 @@ This spec covers shell and MCP behavior in:
- shell routes in `routes/shell_routes.py`;
- the standalone shell helper in `services/shell/service.py`;
- agent shell/background execution in `src/tool_execution.py`, `src/agent_tools/subprocess_tools.py`, `src/bg_jobs.py`, and `src/bg_monitor.py`;
- `src/containment.py`, `src/containment_worker.py`, `src/process_ownership.py`, `src/process_lifecycle.py`, and `src/process_reaper.py`;
- `src/browser_identity.py`, `src/browser_lifecycle.py`, `src/browser_observation.py`, and `src/agent_runtime/process_resources.py`;
- app wiring and startup/shutdown in `app.py`;
- MCP configuration routes in canonical `routes/mcp/mcp_routes.py`, with `routes/mcp_routes.py` as a compatibility shim;
- MCP runtime state in `src/mcp_manager.py`;
@@ -51,11 +49,11 @@ Those endpoints probe local or SSH-remote packages, prepend user install bins fo
## Agent Shell And Background Jobs
`src.tool_execution` owns policy/dispatch for agent-side `bash` execution and the `#!bg` marker; `src.agent_tools.subprocess_tools` owns native shell/Python/host-bridge handlers. A `bash` block whose first line is `#!bg` starts a detached background job instead of holding the chat stream open. Native enforcing execution is unavailable on unsupported platforms, including Windows; compatibility/admin shell selection is a separate path and does not establish native containment.
`src.tool_execution` owns agent-side `bash` execution and the `#!bg` marker. A `bash` block whose first line is `#!bg` starts a detached background job instead of holding the chat stream open. On Windows, request-scoped workspace shell execution prefers Git Bash when available so POSIX-style agent commands and path confinement use the intended shell instead of `cmd.exe` parsing.
`src.bg_jobs` owns disk-backed job state under `data/bg_jobs.json` and `data/bg_jobs/*`. It stores wrapper scripts, logs, exit-code files, timestamps, status, and capped result text.
`src.bg_monitor` owns polling and auto-continuation. A completed job follows up only after exact owner/request/thread/generation linkage is checked, including revalidation after draining the continuation stream. Busy sessions retry; missing/terminal authority does not renew itself. Only after assistant persistence is follow-up marked delivered; suppression of an unfollowable result is distinct from delivery.
`src.bg_monitor` owns polling and auto-continuation. When a job finishes, it injects the job result into the session, drains the agent stream, persists only the assistant continuation plus `bg_result` metadata, and marks the job followed up.
Runtime behavior:
@@ -77,7 +75,10 @@ Runtime behavior:
`src.mcp_manager.McpManager` owns live connection state, stdio/SSE/Streamable HTTP transports, sessions, tool schemas, qualified names, and tool calls. HTTP route operations update both database state and live manager state where applicable. Streamable HTTP connects in a background task, can report `connecting` or `needs_auth`, and surfaces an authorization URL when the OAuth client flow redirects. Enabled configured servers connect concurrently at startup; each server has its own 20-second connection timeout and records `timeout` state without delaying siblings. The startup task has no second outer timeout.
Stdio and SSE connection setup registers the session, exit stack, tool list, and status as one completed unit. If initialization or tool discovery fails before registration, the partial `AsyncExitStack` is closed so transports do not leak into later reconnect attempts.
Stdio and SSE connection setup registers the session, exit stack, tool list,
and status as one completed unit. If initialization or tool discovery fails
before registration, the partial `AsyncExitStack` is closed so transports do
not leak into later reconnect attempts.
`src.agent_tools.admin_tools.do_manage_mcp()` is the agent/admin tool path for MCP config and is re-exported lazily through `src.tool_implementations` for compatibility. It is narrower than the HTTP routes: add is stdio-only, command values are checked against an allowlist/denylist before persistence, and enable/disable primarily flips DB config. `scripts/odysseus-mcp` is config-only; it reads and mutates database rows, redacts env values by default, and does not report live manager connection state.
@@ -92,11 +93,20 @@ Python stdio built-ins:
- RAG;
- email.
The optional browser built-in uses `npx -y @playwright/mcp@latest --headless --caps vision`. It is cache-gated by checking npm's `_npx` cache for the requested package and falling back to `npx --no-install`; uncached/missing browser MCP is logged with install guidance and skipped rather than blocking startup or downloading packages at boot. Image, memory and RAG Python built-ins normally defer to native/code-block descriptions; email remains a qualified MCP schema/prompt exception. Connected browser schemas do not authorize page execution.
The optional browser built-in uses `npx -y @playwright/mcp@latest --headless --caps vision`. It is cache-gated by checking npm's `_npx` cache for the requested package and falling back to `npx --no-install`; uncached/missing browser MCP is logged with install guidance and skipped rather than blocking startup or downloading packages at boot. Python built-ins are omitted from OpenAI function schemas because native/code-block paths already describe those capabilities; the browser built-in is exposed through MCP function schemas when connected.
Built-in Python servers prepend the app root to inherited `PYTHONPATH` rather than replacing the environment, so container/dev site-packages remain visible on initial connect and automatic reconnect. `McpManager.call_tool()` can reconnect a built-in once after an exception only when the call has no bound backend identity. Bound calls return the failure so a fresh connection cannot silently replace the sealed backend incarnation. User-configured MCP servers return the call failure instead of automatic reconnect. Browser calls have a bounded timeout; timed-out calls are abandoned without retry because page state is unknown.
Built-in Python servers prepend the app root to inherited `PYTHONPATH` rather
than replacing the environment, so container/dev site-packages remain visible
on initial connect and automatic reconnect. They can be reconnected once on
tool-call failure. User-configured MCP servers return the call failure instead
of automatic reconnect.
The built-in email MCP server is owner-aware when an owner is supplied by the caller or configured through `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`; if owner-scoped email accounts exist and no owner is available, email MCP fails closed instead of exposing global accounts. Other built-in servers remain process-global/admin trust-boundary tools unless their own subsystem spec says otherwise.
The built-in email MCP server is owner-aware when an owner is supplied by the
caller or configured through `ODYSSEUS_MCP_EMAIL_OWNER` /
`ODYSSEUS_EMAIL_OWNER`; if owner-scoped email accounts exist and no owner is
available, email MCP fails closed instead of exposing global accounts. Other
built-in servers remain process-global/admin trust-boundary tools unless their
own subsystem spec says otherwise.
## Agent MCP Exposure
@@ -118,7 +128,9 @@ After model-visible external/workspace context, arbitrary MCP actions classify f
## Degraded And Platform Behavior
- `app.py` starts the background monitor and MCP startup tasks asynchronously; MCP startup is non-critical to app readiness.
- Configured MCP servers start concurrently with a per-server 20-second bound; timeout state is stored per server and partial connection resources are closed before returning.
- Configured MCP servers start concurrently with a per-server 20-second bound;
timeout state is stored per server and partial connection resources are
closed before returning.
- Missing Python `mcp` dependency degrades attempted MCP connections to error status.
- Missing or uncached browser NPX package is optional and log-only during built-in startup; startup should not perform an implicit package download.
- Windows does not support POSIX PTY/tmux paths; streaming falls back to pipes or detached logfile behavior.
@@ -129,13 +141,16 @@ After model-visible external/workspace context, arbitrary MCP actions classify f
## Security And Provenance
- Admin shell is intentional host command execution; do not expose shell routes or shell tools to regular users.
- `routes.shell_routes._require_admin()` gates shell and selected Cookbook native controls, rejecting internal-tool identity/header administration. Auth-disabled operator access requires direct loopback without forwarding or disallowed browser-origin metadata; an Authorization header alone is not excluded by this transport helper. Auth-enabled administration requires a present auth manager and human admin. MCP configuration instead uses common `core.middleware.require_admin()`, which admits a matching internal token/sentinel or explicit auth-disabled mode; it does not inherit the shell gate's transport/resource rules.
- `_require_admin()` gates shell routes and MCP config routes. The internal-tool loopback can be admin-equivalent only after auth middleware validates the internal token and loopback client.
- `_reject_cross_site()` currently applies to `/api/cookbook/packages`; `/api/shell/exec`, `/api/shell/stream`, package install, rebuild, and MCP write/OAuth routes do not call it directly.
- Shell helper paths use argv-based SSH, reject option-like hosts, validate SSH ports through shared helpers, restrict remote venv characters, and allowlist package installs.
- Non-admin/public tool policy blocks `bash`, `python`, file tools, `manage_mcp`, and all `mcp__*` tools.
- MCP stdio server registration is arbitrary host process execution and is admin-only.
- MCP OAuth key/token file paths supplied through routes are confined under `data/mcp_oauth`; generic Streamable HTTP OAuth token state is encrypted in the database.
- Built-in MCP servers are local/admin trust-boundary tools and are not automatically equivalent to owner-scoped HTTP route behavior. Email MCP is the current exception with explicit owner filtering; other built-ins need their own owner policy before being treated as scoped surfaces.
- Built-in MCP servers are local/admin trust-boundary tools and are not
automatically equivalent to owner-scoped HTTP route behavior. Email MCP is
the current exception with explicit owner filtering; other built-ins need
their own owner policy before being treated as scoped surfaces.
- MCP output is external-untrusted tool output and arms the high-impact continuation gate when model-visible. Current MCP text output is still not centrally capped before model re-entry.
## Testing Notes
@@ -144,54 +159,15 @@ Current targeted coverage includes Windows PTY import degradation, PTY unsupport
The shell/MCP audit ran the targeted venv subset with 78 passing tests and one warning.
## Contained Execution And Native Browser Identity
Model-reachable native shell/Python execution uses `src.containment.agent_spec()` with enforcing filesystem, process-tree, and wall-clock requirements. Missing required mechanisms return containment-unavailable results before code runs; optional network/memory/process-count dimensions are recorded separately and must not be assumed enforced. Bubblewrap provides the Linux filesystem/namespace path when available. Direct admin HTTP shell routes remain a distinct operator surface. Host/client bridges declare external execution rather than claiming local sandbox enforcement.
Process identity is PID plus start token, not PID alone. `src.process_ownership` probes identities; `src.process_lifecycle` protects the server process group and reports death only after observation. Durable callers require positive PID/start-token identity; the generic tree helper also retains a compatibility mode without that requirement, so this is not a universal signaling guarantee. Background jobs persist launch generation, containment, and process identity so stop/restart paths can refuse stale targets.
`private_browser` currently admits only `session_info`. All page navigation, snapshots, screenshots, close/batch/recovery actions and raw global Playwright browser operations are denied unconditionally at the common boundary before binding or dispatch. `src.browser_identity` and lifecycle/observation modules retain owner/thread/session/incarnation machinery and metadata-only prompt support, but these do not enable page execution. The pinned agent-browser 0.35 Linux x64/arm64 discovery, clean environment and read-only bounded CDP observations do not amount to usable browser acceptance; metadata lookup does not adopt or create an unrelated launch.
`McpManager` additionally registers backend incarnations and exposes server-owned resource observations for admitted calls. Built-in connection owner tasks keep AnyIO transport scopes alive until disconnect; changed connections invalidate sealed backend identities. Existing transport configuration and schema discovery remain manager-owned.
## Launch, Cleanup And Bridge Boundaries
Linux native containment requires a functional bounded bubblewrap mount/PID probe, not merely an installed executable. The supervisor/worker ready acknowledgement holds user code until containment publication, durable job state, sidecar and exact launch receipt are verified. Filesystem, process-tree and wall-clock dimensions are required; missing mechanisms fail before code execution. Captured init/leader PID plus start tokens and optional pidfd support cleanup; both deaths must be observed. Unknown identity remains unverified, foreign identity is never a signal target, and no later PID lookup can manufacture a launch receipt. Startup timeout includes bootstrap/stdin; cancellation shields spawn cleanup. Optional network/memory/process-count dimensions are not implied enforcement.
Native background work uses generation-bound launches rather than agent tmux. Operator HTTP tmux remains a compatibility surface. PTY cleanup freezes known descendants before signaling and protects the server group; missing launch PID/token does not trigger PID/group recapture. Unverifiable containment grants remain active; detached jobs with foreign/unverifiable ownership can retire failed without signalling, retaining follow-up history rather than declaring successful cleanup. Publication retirement checks exact launch identity and its caller-specific lifetime rule; foreground startup recovery can also remove publications whose receipt is genuinely absent. Unreadable or malformed nonempty startup state is retained. Dead launch retention/pruning and delivered result retention are bounded lifecycle policies, not proof of task success. Store transaction locks serialize background read/modify/write, not every application store.
`core.platform_compat.service_home()` uses `Path.home()` on Windows and lazy POSIX real-UID passwd lookup. Successful POSIX lookup keeps a child HOME override from choosing the service cache; the missing-entry/OS-error fallback uses `Path.home()` and can honor that override. Default native child environments allow selected keys with fixed HOME/terminal values; an explicitly supplied subprocess environment is not value-scrubbed. This is separate from inherited built-in MCP environments.
Host shell/file bridges declare external execution and normalize the admitted URL authority while replacing the operation path and dropping query/fragment components. Every bridge run/read/write/patch/poll/cancel client uses `trust_env=False`. HTTP admission accepts the explicit loopback/host-docker/LAN/CGNAT or actual Docker-gateway cases, not every private network or arbitrary TLS host; this bridge rule does not apply to every other HTTP client. SSH/external raw shell calls cannot borrow native launch/stop authority. Cookbook and scheduled native launches carry sealed backend capabilities; missing capabilities fail closed.
MCP add-server argument text must decode to a JSON array before mutation; an empty value means an empty array, without an element-type guarantee. Bound backend calls cannot silently reconnect into a different incarnation. Browser timeout abandonment remains no-retry: EOF or partial transport state is not exactly-once execution proof. Email MCP owner and session ContextVars are reset in finally; its owner-filtered listing/index behavior is documented with email ownership.
## Compatibility Process Evidence
Python execution confinement validates the read-only interpreter-prefix mounts it relies on instead of treating an arbitrary interpreter path as admitted. Process inspection helpers retain platform-specific uncertainty: unavailable procfs inspection is not proof that a process died, and generic compatibility liveness helpers are not substitutes for the stored exact launch identities required by background execution cleanup.
## Result Publication And Continuation
Native execution reports containment, teardown and capture truncation independently. Verified timeout uses exit 124; unverified process death is teardown failure rather than successful cleanup, and Python child-runtime failure cannot hide behind exit zero. Detached supervisors publish result JSON before exit metadata; setup failure releases an unstarted grant and falls back to stderr when its failure log cannot be written. Successful exit does not imply complete captured output.
PTY teardown freezes its leader identity and verified session group. FOREIGN identity is not signalled, absent/unverifiable identity does not prove death, and a gone leader can permit cleanup of its recorded surviving group without rediscovering a replacement PID. Timeout SSE reports surviving processes; quiet disconnect/error paths log failed cleanup. Generic compatibility tree helpers still require durable callers to request identity verification.
Background historical lookup does not reap unrelated cached handles: selected running records need their exact frozen PID/start token. Continuation validates immutable job, owner, request, thread and authority linkage before and after awaits, settles the linked launch effect from lifecycle facts rather than output, and persists delivery before marking followed-up. Missing sessions or invalid linkage become terminal_unfollowable; busy sessions/managers retry, and a changed snapshot prevents stale suppression. This is not an atomic lock over the entire chat session.
Foreground startup retirement retains unreadable/malformed stores. With an existing receipt it requires exact linkage and an observed GONE/FOREIGN manager; live or unverifiable managers retain publication even after child teardown. A genuinely missing receipt also permits retirement of a valid foreground publication because attachment cannot resume. Ordinary producer-finally retirement uses verified teardown. One-use launch reservations are shared across approval copies, so deleting publication does not renew dispatch. Retirement is bookkeeping, not authority to signal or proof of process-tree death. Protected-root overlays are refused after normalization; an external bridge response heuristic does not attest local effects.
## Image MCP Ownership
`mcp_servers/image_gen_server.py` reads the hidden trusted-dispatch `_odysseus_owner`, trims blank values to null, stamps `GalleryImage.owner`, and registers a fresh image after gallery commit when an owner exists. There is no image-owner environment fallback. `src/tool_execution.py` removes caller-reserved attribution fields before injecting server owner context for legacy and qualified image calls; direct fallbacks retain the trusted owner. Generated-file access uses the independent fail-closed policy in `gallery-editor-media.md`.
## Current Gaps
- Decide whether `/api/shell/exec`, `/api/shell/stream`, package install, rebuild, and MCP config/OAuth writes should call `_reject_cross_site()` directly.
- Shell route/service and process-lifecycle tests cover selected execution paths; PTY/tmux/Windows behavior remains platform-dependent.
- Background identity, containment, and follow-up paths have focused tests; restart/platform integration still needs separate validation.
- Add route-level shell exec/stream tests for admin gate, cross-site behavior, empty command, plain exec, timeout, PTY, tmux, and Windows detached fallback.
- Add background job tests for launch isolation, output truncation, done/failed/timeout/died states, pending follow-ups, and result text.
- Add route-level MCP CRUD/OAuth/disabled-tool tests with a fake manager and temp database.
- Add hard per-server disabled MCP execution checks or document disabled tools as prompt/schema filtering only.
- MCP prompt descriptions cache against both disabled maps and manager generation; downstream index refresh still needs coordinated invalidation.
- Make MCP tool indexing sensitive to disabled-map changes, not only manager generation.
- Fix stale outer prompt/cache behavior when MCP disabled tools change.
- Add one central truncation layer for MCP result text and images before model re-entry; untrusted-result marking and exact-action continuation approval are now implemented.
- Decide whether `McpServer.env` and OAuth key files need masking, encryption, and chmod beyond admin-only access.
- Decide whether built-in MCP servers should become owner-aware or remain documented as admin/global compatibility surfaces.
+3 -3
View File
@@ -1,6 +1,6 @@
# Speech
Last updated: dev@86b886c | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -16,7 +16,7 @@ This spec covers speech behavior in:
- settings routes in `routes/auth_routes.py`;
- model endpoint cleanup in `routes/model_routes.py`;
- settings/tool aliases in `src/tool_implementations.py`;
- frontend modules `static/js/voiceRecorder.js`, `static/js/tts-ai.js`, `static/app.js`, `static/js/chat.js`, `static/js/slashCommands.js`, `static/js/keyboard-shortcuts.js`, `static/js/settings.js`, `static/js/settings/speech.js`, and `static/index.html`;
- frontend modules `static/js/voiceRecorder.js`, `static/js/tts-ai.js`, `static/app.js`, `static/js/chat.js`, `static/js/slashCommands.js`, `static/js/keyboard-shortcuts.js`, `static/js/settings.js`, and `static/index.html`;
- optional dependency declarations in `requirements-optional.txt`;
- runtime cache path `data/tts_cache/`;
- tests covering speech service toggles, TTS speed/cache, STT temp cleanup, upload limits, settings scrubbing, and model endpoint cleanup.
@@ -80,7 +80,7 @@ Route behavior:
Speech providers are global settings under `data/settings.json`, with defaults in `src/settings.py`. Settings reads are scrubbed for non-admin callers, writes are admin-only, and `manage_settings` can change non-secret speech settings through aliases.
`static/js/settings/speech.js` owns TTS/STT settings initialization and provider selectors; the Settings coordinator imports those initializers. Visible UI state is not complete: backend and JS speech settings exist, the TTS settings card is currently hidden, and the STT settings JS exits when its removed DOM nodes are absent.
Visible UI state is not complete: backend and JS speech settings exist, the TTS settings card is currently hidden, and the STT settings JS exits when its removed DOM nodes are absent.
`routes.model_routes` clears `tts_provider` and `stt_provider` references when a referenced model endpoint is deleted.
+13 -48
View File
@@ -1,6 +1,6 @@
# Testing And Devops
Last updated: dev@c74f475 | 2026-10-15
Last updated: dev@e71f8ce | 2026-08-25
## Scope
@@ -15,7 +15,7 @@ This spec covers development and validation surfaces in:
- `scripts/`, `scripts/odysseus`, `scripts/_lib/cli.py`, `scripts/_completion/*`, `scripts/pr_blocker_audit.py`, and `scripts/odysseus-*`;
- GPU helper scripts `scripts/check-docker-gpu.sh` and `scripts/check-docker-amd-gpu.sh`;
- `.github/` templates, workflows, and description-check scripts;
- contributor workflow docs in `CONTRIBUTING.md` and `website/pr-blocker-audit.md`;
- contributor workflow docs in `CONTRIBUTING.md` and `docs/pr-blocker-audit.md`;
- platform launchers `launch-windows.ps1`, `launcher.py`, `Odysseus.spec`, `build-windows-portable.ps1`, `start-macos.sh`, `build-macos-app.sh`, and `update_windows.bat`;
- setup/service files such as `setup.py`, `install-service.sh`, and `odysseus-ui.service`.
@@ -37,7 +37,7 @@ Activated-venv `python -m pytest <test path>` is equivalent. System/global `pyte
`tests/conftest.py` inserts the repo root on `sys.path` and conditionally stubs missing heavy/runtime dependencies such as SQLAlchemy, FastAPI, Starlette, Pydantic, httpx, bcrypt, and pyotp. Tests that need real dependencies use explicit imports/skips. Tests that stub `sys.modules`, environment variables, globals, or parent packages must restore them with `monkeypatch` or an equivalent cleanup pattern.
The suite currently contains a growing set of `test_*.py` files. Treat that count as a moving source metric, not a target; focused regression tests are still preferred for narrow changes.
The suite currently contains roughly 728 `test_*.py` files. Treat that count as a moving source metric, not a target; focused regression tests are still preferred for narrow changes.
Focused regression tests are preferred for narrow behavior changes. Broaden tests when touching shared contracts such as auth, owner filtering, OAuth/token custody, tool output, context building, provider calls, persistence, frontend rendering, or route/API shapes.
@@ -45,7 +45,7 @@ Focused regression tests are preferred for narrow behavior changes. Broaden test
## JS And UI Tests
`frontend/modules.json` maps canonical `frontend/src/` modules to committed outputs under `static/`. `scripts/build_frontend.py --check` verifies output freshness/publication attributes; `scripts/check_frontend.mjs` checks architecture/import boundaries. Root `package.json` owns Bombadil, Playwright and Acorn plus photo-editor browser-test scripts; the lockfile owns npm integrity/version state. This is an incremental source migration rather than a bundler-wide conversion.
The repo has no frontend build pipeline, npm test script, or type-check script. `package.json` owns Node dependencies for Bombadil and the Anthropic SDK, and `package-lock.json` owns npm integrity/version state.
Current frontend/JS validation includes:
@@ -57,14 +57,13 @@ Use `node --check static/js/<changed-file>.js` for syntax checks on changed JS f
## Dependencies
`requirements.txt` owns core runtime and test dependencies, including `psycopg2-binary`, pytest, pytest-asyncio, MCP, Chroma HTTP client, fastembed, qrcode, and core parsing/search/calendar dependencies.
`requirements.txt` owns core runtime and test dependencies, including pytest, pytest-asyncio, MCP, Chroma HTTP client, fastembed, qrcode, and core parsing/search/calendar dependencies.
`requirements-optional.txt` owns optional feature dependencies:
- `faster-whisper` for local STT;
- `kokoro==0.9.4` and `soundfile` for local TTS on Python 3.11-3.12 only; Kokoro is deliberately skipped on Python 3.13+ because its package metadata excludes those runtimes, and a CUDA-capable torch/GPU is still required at runtime;
- `ddgs` for DDG library support, while provider code can fall back to HTML scraping;
- `rapidocr==3.9.2` and `onnxruntime>=1.20,<2` for local OCR, and `psd-tools` for PSD previews/layer inspection;
- `PyMuPDF` for PDF forms/rendering with AGPL implications for a network-served app;
- `markitdown[docx,pptx,xlsx,xls]` for Office/EPUB extraction, pinned to a release older than 30 days.
@@ -97,8 +96,6 @@ Compose variants forward `ODYSSEUS_TTS_CACHE_MAX_BYTES`, defaulting in the servi
Docker does not mount the host Docker socket by default. Mounting it would grant powerful host access and is outside the default trust boundary. `docker/host-docker.yml` is the explicit opt-in overlay and sets `ODYSSEUS_ENABLE_HOST_DOCKER=true`; tests guard that the default and GPU compose files do not enable host Docker accidentally.
Compose selects `ODYSSEUS_IMAGE` (default GHCR `latest`) alongside a local build and publishes container port 7000 on default host port 7011. SearXNG is tag-and-digest pinned. `docker/host-workspace.yml` explicitly mounts a selected host workspace; `docker/host-network.yml` opts into Linux host networking, resets published ports, and starts uvicorn on the configured host bind/port. These overlays expand access beyond default deployment. The image includes Chromium, Noto CJK fonts, network inspection utilities, pinned `agent-browser@0.35.0`, and redistribution notices. Entrypoint ownership repair touches HuggingFace cache roots without traversing existing model files.
## GPU And Platform
Base `docker-compose.yml` plus `docker/gpu.nvidia.yml` or `docker/gpu.amd.yml` are the GPU source of truth. Top-level `docker-compose.gpu-nvidia.yml` and `docker-compose.gpu-amd.yml` are standalone mirrors for stack-management UIs that accept one compose file. `tests/test_gpu_compose_standalone.py` guards drift between those forms.
@@ -148,7 +145,7 @@ When route/API behavior changes, check whether a matching CLI script depends on
## GitHub Metadata
`.github/` owns issue/PR templates, a copyable PR review template, description-check workflows, security/governance workflows, Docker publishing, and CI. Current CI runs on pushes to `main` and `dev` plus pull requests, compiles Python, checks committed frontend outputs, validates frontend architecture/syntax, emits report-only focused-test guidance, and partitions pytest across four file-based shards through `tests/_shards.py`. Pytest setup requires functional bubblewrap PID/mount containment, FFmpeg and Chromium. Its prose-only bypass excludes `website/` and `assets/branding/`; those published-file contracts remain tested.
`.github/` owns issue/PR templates, a copyable PR review template, description-check workflows, security/governance workflows, Docker publishing, and CI. Current CI runs on pushes to `main` and `dev` plus pull requests, compiles Python with `python -m compileall`, syntax-checks first-party JS with `node --check`, emits focused-test guidance for changed code, and runs the configured `python -m pytest -q` scope as an authoritative failing job; pytest still skips documentation-only changes.
`CONTRIBUTING.md` owns the branch model: PRs target `dev`; `main` is the curated user-running branch fast-forwarded from stable `dev` commits. Contributors who accidentally target `main` should retarget the PR base without rebasing.
@@ -172,9 +169,9 @@ Issue description checks:
- update a bot comment and swap status labels;
- remove the workflow-owned review label when an issue closes so closed issues do not retain stale readiness state.
Security metadata includes container Trivy SARIF upload, Dockerfile lint, dependency review, secret scan, workflow security linting, GitHub default-setup CodeQL, Dependabot metadata, and hardened PR/issue description checks that avoid unsafe head-branch execution. `website/security-ci.md` documents CodeQL as a dynamic GitHub default-setup workflow; the repo should not add a checked-in CodeQL workflow while that default setup is active.
Security metadata includes container Trivy SARIF upload, Dockerfile lint, dependency review, secret scan, workflow security linting, GitHub default-setup CodeQL, Dependabot metadata, and hardened PR/issue description checks that avoid unsafe head-branch execution. `docs/security-ci.md` documents CodeQL as a dynamic GitHub default-setup workflow; the repo should not add a checked-in CodeQL workflow while that default setup is active.
`scripts/pr_blocker_audit.py` is a read-only maintainer/contributor triage helper documented in `website/pr-blocker-audit.md`. It can fetch or ingest open PR metadata, estimate hot files and possible duplicate groups, and emit Markdown, JSON, or terminal reports. Its duplicate/blocker output is advisory, not an authority that a PR is blocked.
`scripts/pr_blocker_audit.py` is a read-only maintainer/contributor triage helper documented in `docs/pr-blocker-audit.md`. It can fetch or ingest open PR metadata, estimate hot files and possible duplicate groups, and emit Markdown, JSON, or terminal reports. Its duplicate/blocker output is advisory, not an authority that a PR is blocked.
Before posting PRs or issues, compare drafts against current templates on latest `main` or current `dev` as appropriate for the target. Keep unpublished drafts and raw related-search exports out of tracked implementation specs unless intentionally promoted.
@@ -204,50 +201,18 @@ Run the app for user-facing or integration changes. Unit tests and syntax checks
`tests/helpers/` owns reusable test scaffolding. `cli_loader.load_script()` loads CLI files without running their `main()` entrypoint. `db_stubs` owns small DB stand-ins for tests that should not import a real app database. `import_state` owns conservative `sys.modules` and parent-module-attribute restoration for tests that install fake modules or import route files under alternate stubs. `tests/README.md` documents helper conventions and review expectations.
`tests/test_runtime_behavior_regressions.py` drives the agent loop with a fake streaming model to check offered tools, clarification/plan policy, explicit native request scope and mocked browser cleanup. Negative-web cases require a model call, an answer delta and terminal completion while withholding web tools. These deterministic runtime assertions do not establish live-model or browser acceptance.
## Harness And Publication Boundaries
`tests/conftest.py` defaults the static origin to an ephemeral loopback port and publishes/restores `ODYSSEUS_TEST_STATIC_ORIGIN`; an explicit validated port can still collide. Threaded serving avoids idle-connection head-of-line blocking. Per-process random private data/mail/embedding/cache/temp paths and optional xdist basetemp avoid sharing runtime state; explicit basetemp wins. Disposable database helpers use isolated factories/cleanup rather than ORM reloads. A teardown guard removes and fails newly leaked bare `src`/`core` modules, using exact ModuleType/falsey-file detection; preexisting modules and mocks are outside that guard. The Unix socket path length fixture is character-bounded, not a universal byte-length proof.
`tests/_shards.py` partitions collected files deterministically by weights and lexical tie-breaks across four CI shards; deselection occurs after collection/import. Optional parallel smoke and serial-only guards do not make every test xdist-safe. Pytest CI uses Node 20, locked npm dependencies, cached Chromium downloads keyed by OS/lockfile, system browser libraries, FFmpeg and a functional unprivileged bubblewrap probe. It uses setup-uv cache without automatic prune and pytest skip reasons/duration reporting. These workflow provisions are not evidence that every browser, media or containment test passed on another host.
`tests/helpers/document_source.py` provides ordered implementation membership and lexical construct extraction; email JS helper enumeration is immediate rather than recursive. Stylesheet helpers follow shell order and cache identities; keyframe/hygiene/import/procfs guards are bounded textual or AST checks, not full semantic control-flow/browser proofs. The computed-style fixture, browser artifacts and reported historical suite counts remain specific to their recorded environments.
`scripts/odysseus-dev` owns separate development data/database/browser-cache paths, path-hashed port blocks, service/reserved-port refusal, readiness login and fingerprinted stop state. Hash collisions, released fallback-port races and environment-derived defaults prevent a universal isolation guarantee. `scripts/odysseus-smoke` exercises deterministic model/email HTTP scenarios and reports declared gaps; it is not full browser/product acceptance and cleanup/reused-instance state remains scoped. `scripts/ref_parity_audit.py` compares heuristic added-line probes and exact filtered tree-path sets, not behavioral equivalence; mutable refs can be reread during a run. `scripts/generate_env_reference.py` checks generated reference bytes and handwritten notes from bounded AST/helper/regex discovery, not exhaustive runtime environment use; malformed Python can yield no detected rows and source citations are file-based.
`.github/workflows/deploy-pages.yml` builds `website/` into `_site`; preview encoding targets website assets and branding lives under `assets/branding/`. Docker excludes both website and branding source roots. The macOS app script uses moved branding with best-effort `sips` icon generation; no native build or license clearance follows from source inspection. Docker publishing emits version/short-SHA trace tags and inspects those plus latest, without registry-enforced immutability. Trivy jobs use the Docker Buildx driver and free local build cache before vulnerability database work; this is CI lifecycle behavior, not an instruction to prune controller state.
Description checks retain the public numeric Linked Issue requirement; an exact maintainer-preview repository allows a beginning `N/A` value while still requiring the section after comment stripping. This checks syntax, not issue existence or branch rulesets. Dependency-review blocking applies to canonical repository pull requests; fork pip-audit advice is a separate policy. None of these workflows grants publication authority.
Maintained frontend publication requires repository-owned LF text attributes on both mapped paths, checked through isolated Git attribute evaluation. It validates the complete plan before writes, copies bytes without rewriting imports or bundling and never deletes unowned static assets; publication is not a transactional live deployment. Acorn checks manifest-selected dependency direction, canonical URL identity, used legacy exceptions, exact precache arrays and cycles, not all legacy browser behavior.
## Chroma Mount And Focused Safety Regressions
All three Compose variants mount `chromadb-data:/data`, matching the current image configuration rather than pre-1.0 `/chroma/chroma`. Existing writable-layer data needs rescue before container recreation; `docs/chromadb-volume-migration.md` describes writer shutdown/cold backup, preserved backup image, SQLite/HNSW checks, empty-volume seeding, isolated staging and count comparison. `website/backup-restore.md` links that procedure. Static YAML/document tests cover declared paths and safety markers; they do not execute or certify migration against a live Chroma image.
Focused regressions now cover invalid history truncation preserving messages, failed bulk Trash moves/selective expunge, disabled-auth app configuration gates, durable cleanup message counts/delete-boundary races and generated-image ownership/cache/attribution. Test presence and historical counts do not establish a new successful run; runtime validation evidence is separate.
## Notes Extraction Validation Surface
Notes tests include domain/characterization/application/infrastructure/reminder/workflow contracts, recurrence differential cases across timezones, raw-state cloneability, draft timing and selected-context acceptance. Browser fixtures exercise canonical/facade identity, desktop/mobile/tablet interactions, teardown and first-time offline graph loading; source guards now read feature modules through `tests/helpers/notes_source.py`. `scripts/verify_frontend_runtime.py` checks all manifest outputs in a Python-only distribution, while `scripts/verify_notes_browser_runtime.py` probes an authenticated temporary native app. These require their declared dependencies/browser environment; committed receipts and screenshots are historical evidence, not a fresh acceptance run. Runtime serving still requires no frontend build or Node installation.
## Skills Extraction And Upload Contract Tests
`tests/test_skills_audit_characterization.py` freezes 22 route declarations, 28 Python interfaces and canonical OpenAPI (17 paths, 22 operations, five schemas) from committed fixtures; constrained fields, supported default factories, model configuration and aliases are checked structurally. Unsupported factories fail without execution. `tests/test_skills_audit_services.py` exercises real route/service wiring with offline model/agent/manager substitutes, including prompt seams, owner/request authority, one-use approvals and cancellation/stream closure. Fixtures do not prove arbitrary validators, live execution or authorization by themselves. ReDoS guards now scan/import `services/skills/evaluation.py`. The Gitleaks exception is constrained to one exact historical digest, the generic-api-key rule and two named paths; this maintenance does not claim a new full-history scan.
`tests/test_upload_no_login.py` covers disabled-auth access after setup, missing/unconfigured manager failures, token ownership/scopes, legacy ownerless uploads and OCR preservation on rejection. Its loopback-host parametrization does not itself change TestClient transport identity, so source behavior and narrower assertions remain the evidence for absence of a loopback bypass.
## Current Gaps
- Fresh install smoke coverage across Linux native, Docker, macOS native/app, Windows native, WSL/Git Bash, missing Node/npm, missing Chroma service, and GPU overlays remains a roadmap item.
- Frontend source/output and architecture gates exist; they do not establish full browser or platform correctness.
- Real deployment, GPU and cross-platform install verification still require separate runs beyond CI gates.
- There is no frontend build/type-check/npm test pipeline.
- CI now covers Python compile, first-party JS syntax, focused-test guidance,
and pytest smoke; it does not cover Docker compose validation, launcher smoke
tests, browser/module-graph execution, or platform installs.
- Optional dependency behavior is broad; remaining gaps include local STT missing-`faster-whisper`, Kokoro's Python/GPU degraded matrix, and provider/OAuth combinations not covered by focused tests.
- GitHub description-check scripts and `scripts/pr_blocker_audit.py` need continued local fixtures for section parsing, placeholder stripping, label swaps, workflow-safe behavior, and duplicate/hot-file heuristics.
- Spec bootstrap rules lack meta tests for reading `_readme.md`, spec shape, `.env*` handling, draft/report placement, and shared helper conventions.
- NVIDIA helper install/`.env` mutation paths and real Docker/GPU startup are not covered by local tests.
- Bash/Zsh completion behavior is not covered.
- `tests/KNOWN_FAILURES.md` records observed environment-dependent full-suite failures and prerequisites; its measured basis is historical, so verify applicability rather than treating it as a passing waiver.
- There is no canonical full-suite known-failing/flaky ledger.
- There is no central CLI redaction/sensitive-output regression matrix across backup, logs, mail, MCP, tasks, and webhook scripts.
- Dependency/image pinning policy is mixed: Python requirements are mostly unpinned, SearXNG is pinned, Chroma image currently uses `latest`, npm uses a lockfile, and browser MCP uses cache-gated `@playwright/mcp@latest`.
+158 -7
View File
@@ -1122,7 +1122,130 @@ function _filenameFromContentDisposition(header) {
return bare ? bare[1].trim() : '';
}
export async function _exportSelectedAttachments() {
let _emailHtml2PdfReady = null;
async function _loadEmailHtml2Pdf() {
if (window.html2pdf) return;
if (!_emailHtml2PdfReady) {
_emailHtml2PdfReady = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/static/lib/html2pdf.bundle.min.js';
script.onload = resolve;
script.onerror = () => reject(new Error('Could not load PDF exporter'));
document.head.appendChild(script);
});
}
try {
await _emailHtml2PdfReady;
} catch (error) {
_emailHtml2PdfReady = null;
throw error;
}
}
async function _downloadSelectedEmailPdf(messages) {
if (messages.length > 20) throw new Error('PDF export supports up to 20 emails at a time');
await _loadEmailHtml2Pdf();
const loaded = new Array(messages.length);
let nextIndex = 0;
const readWorker = async () => {
while (nextIndex < messages.length) {
const index = nextIndex++;
const message = messages[index];
const account = message.account_id ? `&account_id=${encodeURIComponent(message.account_id)}` : '';
const url = `${API_BASE}/api/email/read/${encodeURIComponent(message.uid)}?folder=${encodeURIComponent(message.folder || 'INBOX')}${account}&mark_seen=false&full=true`;
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) throw new Error(`Could not load "${message.subject || 'email'}" (HTTP ${response.status})`);
const data = await response.json();
if (data?.error) throw new Error(data.error);
loaded[index] = { message, data };
}
};
await Promise.all(Array.from({ length: Math.min(4, messages.length) }, readWorker));
const page = document.createElement('div');
page.className = 'email-export-pdf';
page.style.cssText = 'width:794px;box-sizing:border-box;padding:38px;background:#fff;color:#171717;font:12px/1.55 Arial,Helvetica,sans-serif;';
const style = document.createElement('style');
style.textContent = `
.email-export-pdf-message { page-break-after: always; break-after: page; }
.email-export-pdf-message:last-child { page-break-after: auto; break-after: auto; }
.email-export-pdf-message h1 { margin: 0 0 18px; font-size: 20px; line-height: 1.3; overflow-wrap: anywhere; }
.email-export-pdf-fields { margin: 0 0 20px; padding: 0 0 14px; border-bottom: 1px solid #d8d8d8; }
.email-export-pdf-field { display: flex; gap: 10px; margin: 3px 0; overflow-wrap: anywhere; }
.email-export-pdf-label { width: 54px; flex: 0 0 54px; color: #666; }
.email-export-pdf-body { overflow-wrap: anywhere; word-break: break-word; }
.email-export-pdf-body img { max-width: 100%; }
.email-export-pdf-body a { color: #135f9b; text-decoration: underline; }
.email-export-pdf-body blockquote { margin-left: 12px; padding-left: 10px; border-left: 2px solid #ccc; color: #555; }
.email-export-pdf-body pre { white-space: pre-wrap; }
`;
page.appendChild(style);
const addField = (parent, label, value) => {
if (!value) return;
const row = document.createElement('div');
row.className = 'email-export-pdf-field';
const labelEl = document.createElement('span');
labelEl.className = 'email-export-pdf-label';
labelEl.textContent = label;
const valueEl = document.createElement('span');
valueEl.textContent = value;
row.append(labelEl, valueEl);
parent.appendChild(row);
};
for (const { message, data } of loaded) {
const article = document.createElement('article');
article.className = 'email-export-pdf-message';
const subject = String(data.subject || message.subject || '(no subject)');
const title = document.createElement('h1');
title.textContent = subject;
article.appendChild(title);
const fields = document.createElement('div');
fields.className = 'email-export-pdf-fields';
const from = data.from_name && data.from_address
? `${data.from_name} <${data.from_address}>`
: data.from_name || data.from_address;
addField(fields, 'From', from);
addField(fields, 'To', data.to);
addField(fields, 'Cc', data.cc);
addField(fields, 'Date', data.date_display || data.date || message.date);
article.appendChild(fields);
const body = document.createElement('div');
body.className = 'email-export-pdf-body';
const safeBody = new DOMParser().parseFromString(_safeRenderEmailBody(data), 'text/html');
safeBody.querySelectorAll('img').forEach(img => {
const placeholder = safeBody.createElement('span');
placeholder.textContent = img.alt ? `[Image: ${img.alt}]` : '[Inline image omitted]';
img.replaceWith(placeholder);
});
body.innerHTML = safeBody.body.innerHTML;
body.querySelectorAll('details').forEach(detail => { detail.open = true; });
article.appendChild(body);
page.appendChild(article);
}
const safeBaseName = messages.length === 1
? String(loaded[0]?.data?.subject || messages[0]?.subject || 'email').replace(/[^\p{L}\p{N} ._-]+/gu, '_').trim().slice(0, 100) || 'email'
: 'selected-emails';
try {
await window.html2pdf().set({
margin: 10,
filename: `${safeBaseName}.pdf`,
image: { type: 'jpeg', quality: 0.92 },
html2canvas: { scale: 1.6, useCORS: false, backgroundColor: '#fff' },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: { mode: ['css', 'legacy'] },
}).from(page).save();
} finally {
page.remove();
}
}
async function _exportSelectedBundle(kind) {
const messages = _selectedEmailExportPayload();
if (!messages.length) {
showToast('Select emails first');
@@ -1150,8 +1273,15 @@ export async function _exportSelectedAttachments() {
if (deleteBtn) deleteBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `Exporting attachments from ${messages.length}…`;
const res = await fetch(`${API_BASE}/api/email/attachments-download-bulk`, {
const isEmailExport = kind === 'emails';
const isPdfExport = kind === 'pdf';
if (countEl) countEl.textContent = `${isPdfExport ? 'Creating PDF' : 'Exporting'} ${isPdfExport ? 'from' : isEmailExport ? 'emails from' : 'attachments from'} ${messages.length}…`;
if (isPdfExport) {
await _downloadSelectedEmailPdf(messages);
showToast(`Exported ${messages.length} email${messages.length === 1 ? '' : 's'} as PDF`);
return;
}
const res = await fetch(`${API_BASE}/api/email/${isEmailExport ? 'messages' : 'attachments'}-download-bulk`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -1169,12 +1299,21 @@ export async function _exportSelectedAttachments() {
throw new Error(detail || `HTTP ${res.status}`);
}
const blob = await res.blob();
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'selected-email-attachments.zip';
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition'))
|| (isEmailExport
? (messages.length === 1 ? 'selected-email.eml' : 'selected-emails.zip')
: 'selected-email-attachments.zip');
_downloadBlob(blob, filename);
showToast(`Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
const exported = Number(res.headers.get('X-Exported-Emails')) || messages.length;
const skipped = Number(res.headers.get('X-Skipped-Emails')) || 0;
showToast(skipped
? `Exported ${exported} email${exported === 1 ? '' : 's'}; ${skipped} unavailable`
: isEmailExport
? `Exported ${exported} email${exported === 1 ? '' : 's'} as ${exported === 1 ? 'an .eml file' : '.eml files'}`
: `Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
} catch (err) {
console.error('Failed to export selected email attachments:', err);
showToast(err?.message || 'Failed to export attachments');
console.error(`Failed to export selected email ${kind}:`, err);
showToast(err?.message || (kind === 'emails' ? 'Failed to export emails' : 'Failed to export attachments'));
} finally {
if (busySpinner) busySpinner.destroy();
if (actionsBtn) {
@@ -1190,6 +1329,18 @@ export async function _exportSelectedAttachments() {
}
}
export async function _exportSelectedEmails() {
return _exportSelectedBundle('emails');
}
export async function _exportSelectedEmailPdf() {
return _exportSelectedBundle('pdf');
}
export async function _exportSelectedAttachments() {
return _exportSelectedBundle('attachments');
}
// URL-suffix helper — appends &account_id=... when an account is actively selected.
// Every email route call in this file goes through here so switching accounts
// is a single-variable flip.
+44 -2
View File
@@ -25,6 +25,8 @@ import {
_animateEmailCardRemoval,
_clearDoneResponseTagsLocal,
_emailMutationQuery,
_exportSelectedEmailPdf,
_exportSelectedEmails,
_exportSelectedAttachments,
_findSiblingEmailCard,
_fitEmailDropdown,
@@ -585,7 +587,10 @@ export function _showBulkActionsMenu(anchor) {
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const _emailIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>';
const _pdfIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 15h8M8 18h5"/></svg>';
const items = [
{ label: 'Export Emails', icon: _exportIco, submenu: true },
{ label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
@@ -594,8 +599,45 @@ export function _showBulkActionsMenu(anchor) {
for (const a of items) {
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(); close(); a.action(); });
it.className += a.submenu ? ' email-bulk-submenu-trigger' : '';
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>${a.submenu ? '<span style="margin-left:auto;opacity:0.55;">›</span>' : ''}`;
it.addEventListener('click', (e) => {
e.stopPropagation();
if (a.submenu) {
dropdown.replaceChildren();
const back = document.createElement('div');
back.className = 'dropdown-item-compact email-bulk-submenu-back';
back.innerHTML = '<span style="opacity:.65;margin-right:4px;">‹</span><span>Export Emails</span>';
back.addEventListener('click', (ev) => {
ev.stopPropagation();
close();
_showBulkActionsMenu(anchor);
});
dropdown.appendChild(back);
const divider = document.createElement('div');
divider.className = 'dropdown-divider';
dropdown.appendChild(divider);
const options = [
{ label: 'Email file (.eml)', icon: _emailIco, action: _exportSelectedEmails },
{ label: 'PDF', icon: _pdfIco, action: _exportSelectedEmailPdf },
];
for (const option of options) {
const child = document.createElement('div');
child.className = 'dropdown-item-compact';
child.innerHTML = `<span class="dropdown-icon">${option.icon}</span><span>${option.label}</span>`;
child.addEventListener('click', (ev) => {
ev.stopPropagation();
close();
option.action();
});
dropdown.appendChild(child);
}
_fitEmailDropdown(dropdown, anchor.getBoundingClientRect());
return;
}
close();
a.action();
});
dropdown.appendChild(it);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
+25 -6
View File
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
assert "_libCacheWriteBack()" in src
def test_email_bulk_export_attachments_is_ui_only_selected_context():
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
export_src = frontend[
frontend.index("async function _exportSelectedAttachments()"):
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
]
export_src = _function_source("_exportSelectedBundle")
payload_src = _function_source("_selectedEmailExportPayload")
assert "Export Emails" in frontend
assert "Email file (.eml)" in frontend
assert "_exportSelectedEmailPdf" in frontend
assert "Export Attachments" in frontend
assert "All in view" in frontend
assert "/api/email/attachments-download-bulk" in export_src
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
assert "method: 'POST'" in export_src
assert "res.blob()" in export_src
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
@@ -63,6 +63,25 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context():
assert "_fixture_email_read(uid, folder, owner)" in backend
assert "_is_likely_signature_image_attachment(att)" in backend
assert '@router.post("/messages-download-bulk")' in backend
assert '"(UID RFC822)"' in backend
assert "msg.as_bytes(policy=email_policy.SMTP)" in backend
assert 'media_type="message/rfc822"' in backend
assert '"X-Exported-Emails"' in backend
def test_email_bulk_pdf_export_reads_without_marking_read_and_sanitizes_body():
frontend = email_library_source()
pdf_src = _function_source("_downloadSelectedEmailPdf")
menu_src = js_function_source("_showBulkActionsMenu", frontend)
assert "mark_seen=false&full=true" in pdf_src
assert "_safeRenderEmailBody(data)" in pdf_src
assert "new DOMParser()" in pdf_src
assert "window.html2pdf()" in pdf_src
assert "up to 20 emails" in pdf_src
assert "PDF" in menu_src
def test_fixture_tag_filter_supports_receipt_tag_flow():
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")