mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 22:02:20 +02:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8842526b | ||
|
|
d0d8edf5d8 | ||
|
|
b4d12932a9 | ||
|
|
85297cee44 | ||
|
|
981652358e | ||
|
|
5c835014ac | ||
|
|
43682d4e2e | ||
|
|
032967af4b | ||
|
|
0e03aea134 | ||
|
|
2a6b09b968 | ||
|
|
1a2d889c33 | ||
|
|
517946d778 | ||
|
|
8cb8b074a4 | ||
|
|
ee252e7cd9 | ||
|
|
0af6a99e81 | ||
|
|
f562bfee01 | ||
|
|
0728b994d8 | ||
|
|
db05175e3e | ||
|
|
e4046aa41f | ||
|
|
71f30fcc9d | ||
|
|
b19d327f03 | ||
|
|
d0bf771f9d | ||
|
|
45fc3938e0 |
+6
-2
@@ -88,8 +88,12 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
|
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
|
||||||
# LOCALHOST_BYPASS=false
|
# LOCALHOST_BYPASS=false
|
||||||
|
|
||||||
# Mark session cookies Secure. Set true when Odysseus is served through HTTPS
|
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
||||||
# by a trusted reverse proxy or private access gateway.
|
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
||||||
|
# force it on, or false to force it off while you still serve plain HTTP.
|
||||||
|
# Upgrading: this used to default to false. Drop a leftover SECURE_COOKIES=false
|
||||||
|
# from your .env unless you still need that escape hatch — it keeps HTTPS logins
|
||||||
|
# on a non-Secure cookie.
|
||||||
# SECURE_COOKIES=true
|
# SECURE_COOKIES=true
|
||||||
|
|
||||||
# Optional: pre-seed the first admin password during setup.
|
# Optional: pre-seed the first admin password during setup.
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ docker/entrypoint.sh text eol=lf
|
|||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
|
|
||||||
|
# Vendored third-party bundles in static/lib/ are published minified artifacts
|
||||||
|
# and must stay byte-identical to what npm ships — stripping trailing whitespace
|
||||||
|
# to satisfy `git diff --check` would desync them from the upstream release. Turn
|
||||||
|
# the whitespace check off for that tree instead, and keep the bundles out of
|
||||||
|
# GitHub's language statistics.
|
||||||
|
static/lib/** -whitespace linguist-vendored
|
||||||
|
|
||||||
# Binary assets — never normalize.
|
# Binary assets — never normalize.
|
||||||
*.png binary
|
*.png binary
|
||||||
*.jpg binary
|
*.jpg binary
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ body:
|
|||||||
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
|
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: revision
|
||||||
|
attributes:
|
||||||
|
label: Odysseus Revision
|
||||||
|
description: |
|
||||||
|
From the repository root (on the host when using Docker), run
|
||||||
|
`git show -s --abbrev=12 --format='%h (%cs)' HEAD`
|
||||||
|
and paste the output exactly.
|
||||||
|
placeholder: "1fef4929cf1d (2026-08-11)"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: install-method
|
id: install-method
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'bug': {
|
case 'bug': {
|
||||||
|
const revisionText = section('Odysseus Revision');
|
||||||
|
if (!/^[0-9a-f]{12} \(\d{4}-\d{2}-\d{2}\)$/i.test(revisionText)) {
|
||||||
|
failures.push(
|
||||||
|
'**Odysseus Revision** — paste the 12-character commit SHA and date, ' +
|
||||||
|
'for example `1fef4929cf1d (2026-08-11)`',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!section('Install Method')) {
|
if (!section('Install Method')) {
|
||||||
failures.push('**Install Method** — select how you installed Odysseus');
|
failures.push('**Install Method** — select how you installed Odysseus');
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -65,6 +65,16 @@ Vendored in `static/lib/` and served directly:
|
|||||||
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
|
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
|
||||||
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
|
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
|
||||||
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
|
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
|
||||||
|
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
|
||||||
|
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
|
||||||
|
|
||||||
|
KaTeX and Mermaid are loaded on first use by `static/js/markdown.js` rather than
|
||||||
|
from `index.html`, so a session that renders no math and no diagram never fetches
|
||||||
|
either. Only the `.woff2` KaTeX fonts are shipped, matching `static/fonts/`; the
|
||||||
|
`.woff` and `.ttf` variants its stylesheet also lists are never requested by a
|
||||||
|
browser that supports `woff2`. The bundles are the published npm artifacts,
|
||||||
|
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
|
||||||
|
so they can stay byte-identical to upstream.
|
||||||
|
|
||||||
## Front-end libraries loaded at runtime (CDN)
|
## Front-end libraries loaded at runtime (CDN)
|
||||||
|
|
||||||
@@ -72,8 +82,6 @@ Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not v
|
|||||||
|
|
||||||
| Library | Purpose | License |
|
| Library | Purpose | License |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT |
|
|
||||||
| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT |
|
|
||||||
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
|
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
|
||||||
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
|
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=odysseus-dev%2Fodysseus&type=date&legend=top-left">
|
<a href="https://star-history.dera.page/#odysseus-dev/odysseus&type=date&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ Security fixes are handled on the default branch until formal releases are cut.
|
|||||||
|
|
||||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||||
- Set `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Set `SECURE_COOKIES=true` to force it on (for a proxy Odysseus cannot see the scheme of), or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
||||||
- Use HTTPS when exposing the app beyond localhost.
|
- Use HTTPS when exposing the app beyond localhost.
|
||||||
- Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN.
|
- Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN.
|
||||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only.
|
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only.
|
||||||
|
|||||||
@@ -784,7 +784,7 @@ from src.task_scheduler import TaskScheduler
|
|||||||
task_scheduler = TaskScheduler(session_manager)
|
task_scheduler = TaskScheduler(session_manager)
|
||||||
from src.event_bus import set_task_scheduler
|
from src.event_bus import set_task_scheduler
|
||||||
set_task_scheduler(task_scheduler)
|
set_task_scheduler(task_scheduler)
|
||||||
from routes.task_routes import setup_task_routes
|
from routes.task.task_routes import setup_task_routes
|
||||||
app.include_router(setup_task_routes(task_scheduler))
|
app.include_router(setup_task_routes(task_scheduler))
|
||||||
|
|
||||||
from routes.assistant_routes import setup_assistant_routes
|
from routes.assistant_routes import setup_assistant_routes
|
||||||
|
|||||||
+28
-10
@@ -30,11 +30,20 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
|
|||||||
"""
|
"""
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=indent)
|
try:
|
||||||
f.flush()
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
os.fsync(f.fileno())
|
json.dump(data, f, indent=indent)
|
||||||
os.replace(tmp, path)
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
# Directly unlink to avoid a check-then-act race condition.
|
||||||
|
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_text(path: str, text: str) -> None:
|
def atomic_write_text(path: str, text: str) -> None:
|
||||||
@@ -42,8 +51,17 @@ def atomic_write_text(path: str, text: str) -> None:
|
|||||||
raise TypeError("atomic_write_text expects a string")
|
raise TypeError("atomic_write_text expects a string")
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
f.write(text)
|
try:
|
||||||
f.flush()
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
os.fsync(f.fileno())
|
f.write(text)
|
||||||
os.replace(tmp, path)
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
# Directly unlink to avoid a check-then-act race condition.
|
||||||
|
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
+51
-1
@@ -8,6 +8,11 @@ These are simple datacontainers. All persistence is handled by SessionManager.
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from src.tool_approval_scopes import (
|
||||||
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||||
|
CHAT_SESSION_APPROVAL_DECISION,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .session_manager import SessionManager
|
from .session_manager import SessionManager
|
||||||
|
|
||||||
@@ -31,6 +36,35 @@ set_session_manager = set_session_manager_instance
|
|||||||
get_session_manager = get_session_manager_instance
|
get_session_manager = get_session_manager_instance
|
||||||
|
|
||||||
|
|
||||||
|
def _history_grants_chat_session_approval(
|
||||||
|
history: List["ChatMessage"],
|
||||||
|
session_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether this exact chat has a resolved session-scope grant."""
|
||||||
|
|
||||||
|
expected_session = str(session_id or "")
|
||||||
|
if not expected_session:
|
||||||
|
return False
|
||||||
|
for message in reversed(history or []):
|
||||||
|
metadata = getattr(message, "metadata", None)
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
continue
|
||||||
|
tool_events = metadata.get("tool_events")
|
||||||
|
if not isinstance(tool_events, list):
|
||||||
|
continue
|
||||||
|
for event in reversed(tool_events):
|
||||||
|
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
||||||
|
if not isinstance(ask_user, dict):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
ask_user.get("kind") == "tool_approval"
|
||||||
|
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
||||||
|
and str(ask_user.get("session_id") or "") == expected_session
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChatMessage:
|
class ChatMessage:
|
||||||
"""A single chat message."""
|
"""A single chat message."""
|
||||||
@@ -116,11 +150,27 @@ class Session:
|
|||||||
the model. Display/history-load paths use the raw ``history`` and are
|
the model. Display/history-load paths use the raw ``history`` and are
|
||||||
unaffected.
|
unaffected.
|
||||||
"""
|
"""
|
||||||
return [
|
messages = [
|
||||||
msg.to_dict()
|
msg.to_dict()
|
||||||
for msg in self.history
|
for msg in self.history
|
||||||
if (msg.metadata or {}).get("source") != "slash"
|
if (msg.metadata or {}).get("source") != "slash"
|
||||||
]
|
]
|
||||||
|
if not _history_grants_chat_session_approval(self.history, self.id):
|
||||||
|
return messages
|
||||||
|
|
||||||
|
# Keep the grant close to the latest user request so route-neutral
|
||||||
|
# compaction/trimming preserves it. Copy the metadata instead of
|
||||||
|
# mutating the durable transcript object.
|
||||||
|
for index in range(len(messages) - 1, -1, -1):
|
||||||
|
if messages[index].get("role") != "user":
|
||||||
|
continue
|
||||||
|
message = dict(messages[index])
|
||||||
|
metadata = dict(message.get("metadata") or {})
|
||||||
|
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
|
||||||
|
message["metadata"] = metadata
|
||||||
|
messages[index] = message
|
||||||
|
break
|
||||||
|
return messages
|
||||||
|
|
||||||
def get(self, key: str, default=None):
|
def get(self, key: str, default=None):
|
||||||
"""Dict-like access for compatibility."""
|
"""Dict-like access for compatibility."""
|
||||||
|
|||||||
+17
-1
@@ -14,6 +14,8 @@ import logging
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
||||||
from .models import Session, ChatMessage
|
from .models import Session, ChatMessage
|
||||||
from src.attachment_refs import persistable_message_content
|
from src.attachment_refs import persistable_message_content
|
||||||
@@ -92,14 +94,28 @@ class SessionManager:
|
|||||||
try:
|
try:
|
||||||
db_sessions = db.query(DbSession).filter(
|
db_sessions = db.query(DbSession).filter(
|
||||||
DbSession.archived == False,
|
DbSession.archived == False,
|
||||||
DbSession.message_count > 0,
|
DbSession.messages.any(),
|
||||||
).order_by(DbSession.last_accessed.desc()).limit(100).all()
|
).order_by(DbSession.last_accessed.desc()).limit(100).all()
|
||||||
|
|
||||||
|
# message_count is derived metadata and can drift after interrupted
|
||||||
|
# or legacy writes. Count only the bounded discovery set so startup
|
||||||
|
# remains metadata-only while lazy hydration sees an authoritative
|
||||||
|
# positive count for every discovered non-empty session.
|
||||||
|
message_counts = {}
|
||||||
|
if db_sessions:
|
||||||
|
message_counts = dict(
|
||||||
|
db.query(DbChatMessage.session_id, func.count(DbChatMessage.id))
|
||||||
|
.filter(DbChatMessage.session_id.in_([row.id for row in db_sessions]))
|
||||||
|
.group_by(DbChatMessage.session_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
loaded_count = 0
|
loaded_count = 0
|
||||||
for db_session in db_sessions:
|
for db_session in db_sessions:
|
||||||
try:
|
try:
|
||||||
session = self._db_to_session_meta(db_session)
|
session = self._db_to_session_meta(db_session)
|
||||||
if session is not None:
|
if session is not None:
|
||||||
|
session.message_count = message_counts[db_session.id]
|
||||||
self.sessions[db_session.id] = session
|
self.sessions[db_session.id] = session
|
||||||
loaded_count += 1
|
loaded_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ services:
|
|||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ services:
|
|||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||||
|
|||||||
+30
-8
@@ -441,10 +441,19 @@ A grab-bag of small gotchas that otherwise turn into long debugging sessions.
|
|||||||
| Package | Feature unlocked |
|
| Package | Feature unlocked |
|
||||||
|---------|-----------------|
|
|---------|-----------------|
|
||||||
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
|
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
|
||||||
|
| `kokoro`, `soundfile` | Local Kokoro-82M text-to-speech on a CUDA GPU. The pinned Kokoro release supports Odysseus installs on Python 3.11-3.12; these packages are intentionally skipped on Python 3.13+ (including the Python 3.14 container image). |
|
||||||
| `ddgs` | DuckDuckGo as a search provider option. |
|
| `ddgs` | DuckDuckGo as a search provider option. |
|
||||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
||||||
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||||
|
|
||||||
|
Install the optional set only when you need these features:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-optional.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The default Docker image currently uses Python 3.14, while Kokoro 0.9.4 declares Python `>=3.10,<3.13`. Odysseus itself continues to support Python 3.11+, but this pinned optional local-TTS feature requires a native Python 3.11 or 3.12 environment. Kokoro declares `torch`, but the local provider only activates when that torch build has CUDA and a GPU is visible; install the CUDA build appropriate for your host. Browser and configured endpoint TTS remain available on Python 3.13+ and in the container image.
|
||||||
|
|
||||||
### Faster, reproducible installs with uv (optional)
|
### Faster, reproducible installs with uv (optional)
|
||||||
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
||||||
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
||||||
@@ -475,7 +484,7 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
|
|||||||
|
|
||||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||||
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Use `SECURE_COOKIES=true` to force it on for a proxy whose scheme Odysseus cannot see, or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
||||||
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
||||||
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
|
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
|
||||||
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
|
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
|
||||||
@@ -486,6 +495,14 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
|
|||||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
||||||
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
|
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
|
||||||
|
|
||||||
|
> **Upgrading an existing install:** `SECURE_COOKIES` used to default to
|
||||||
|
> `false`, so an install set up before scheme derivation may still carry
|
||||||
|
> `SECURE_COOKIES=false` in its own `.env`. That explicit value stays
|
||||||
|
> authoritative, so HTTPS logins keep getting a non-`Secure` session cookie.
|
||||||
|
> Pulling this change updates the tracked Compose files, but nothing rewrites
|
||||||
|
> your `.env` — drop the line from it unless you deliberately serve plain HTTP
|
||||||
|
> alongside HTTPS and want the escape hatch.
|
||||||
|
|
||||||
### Private or proxied deployments
|
### Private or proxied deployments
|
||||||
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
|
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
|
||||||
|
|
||||||
@@ -494,7 +511,7 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
|
|||||||
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
||||||
4. Keep raw service and model ports internal-only.
|
4. Keep raw service and model ports internal-only.
|
||||||
|
|
||||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true` and `LOCALHOST_BYPASS=false`. Any proxy that forwards `X-Forwarded-Proto: https` gets `Secure` session cookies without configuration, so `SECURE_COOKIES` only needs setting when you want to override that — force it on for a proxy that forwards no scheme at all, or off while you still serve plain HTTP.
|
||||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||||
|
|
||||||
#### Faster over the network: HTTP/2
|
#### Faster over the network: HTTP/2
|
||||||
@@ -582,9 +599,12 @@ Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
|
|||||||
run the proxy as another container, or on the host pointing at the published
|
run the proxy as another container, or on the host pointing at the published
|
||||||
port.
|
port.
|
||||||
|
|
||||||
**4. Point Odysseus at the new origin** in `.env`, then restart it:
|
**4. Point Odysseus at the new origin** in `.env`, then restart it.
|
||||||
|
|
||||||
|
A proxy that exposes the HTTPS request scheme to Odysseus needs no `SECURE_COOKIES` setting. Only force it on when the proxy cannot expose that scheme:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# only if the proxy cannot expose the external HTTPS scheme to Odysseus:
|
||||||
SECURE_COOKIES=true
|
SECURE_COOKIES=true
|
||||||
# only if you use remote MCP servers with OAuth:
|
# only if you use remote MCP servers with OAuth:
|
||||||
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
|
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
|
||||||
@@ -619,10 +639,12 @@ enable it by right-clicking the column headers.
|
|||||||
|
|
||||||
Three things bite when moving an existing install behind TLS:
|
Three things bite when moving an existing install behind TLS:
|
||||||
|
|
||||||
- Set `SECURE_COOKIES=true` **at the same time** you stop serving plain HTTP,
|
- Leave `SECURE_COOKIES` unset when Odysseus can see the external HTTPS scheme;
|
||||||
not before. The flag is applied to every login regardless of the scheme the
|
the cookie then follows the request automatically. If your proxy cannot expose
|
||||||
request arrived on, so while an HTTP entrypoint is still reachable the
|
that scheme, set `SECURE_COOKIES=true` **at the same time** you stop serving
|
||||||
browser will reject the `Secure` cookie there and login will appear to loop.
|
plain HTTP, not before. An explicit `true` applies to every login, so while an
|
||||||
|
HTTP entrypoint is still reachable the browser will reject the `Secure` cookie
|
||||||
|
there and login will appear to loop.
|
||||||
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
|
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
|
||||||
Gmail redirect URI it cannot be derived from a request — it is registered
|
Gmail redirect URI it cannot be derived from a request — it is registered
|
||||||
with each MCP authorization server up front — so set it to the external
|
with each MCP authorization server up front — so set it to the external
|
||||||
@@ -675,7 +697,7 @@ Key settings:
|
|||||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||||
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
||||||
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
| `SECURE_COOKIES` | derived from the request scheme | Marks session cookies `Secure` on HTTPS requests. Set true to force it on, false to force it off. |
|
||||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||||
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
||||||
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 - 2022 Knut Sveidqvist
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -12,6 +12,16 @@
|
|||||||
# GPU-accelerated transcription — it's auto-detected, CPU is used otherwise.
|
# GPU-accelerated transcription — it's auto-detected, CPU is used otherwise.
|
||||||
faster-whisper
|
faster-whisper
|
||||||
|
|
||||||
|
# Local text-to-speech via Kokoro-82M for the "local" TTS provider.
|
||||||
|
# Kokoro 0.9.4 declares Python >=3.10,<3.13; Odysseus itself requires 3.11+,
|
||||||
|
# so pip installs these extras on 3.11-3.12 and deliberately skips them on
|
||||||
|
# Python 3.13+ (including the Python 3.14 container image). Kokoro declares
|
||||||
|
# torch; the local provider still
|
||||||
|
# requires a CUDA-enabled torch build and GPU at runtime. SoundFile is separate
|
||||||
|
# in Kokoro's official install instructions and is not a transitive dependency.
|
||||||
|
kokoro==0.9.4; python_version >= "3.11" and python_version < "3.13"
|
||||||
|
soundfile; python_version >= "3.11" and python_version < "3.13"
|
||||||
|
|
||||||
# DuckDuckGo as a search provider option.
|
# DuckDuckGo as a search provider option.
|
||||||
# Install if you want DDG in the search-provider dropdown.
|
# Install if you want DDG in the search-provider dropdown.
|
||||||
# Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE.
|
# Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE.
|
||||||
|
|||||||
+28
-1
@@ -86,6 +86,33 @@ class SetOpenRegistrationRequest(BaseModel):
|
|||||||
SESSION_COOKIE = "odysseus_session"
|
SESSION_COOKIE = "odysseus_session"
|
||||||
|
|
||||||
|
|
||||||
|
def _secure_cookie(request: Request) -> bool:
|
||||||
|
"""Decide the ``Secure`` attribute of the session cookie.
|
||||||
|
|
||||||
|
``SECURE_COOKIES`` stays authoritative when it holds an explicit value:
|
||||||
|
``true`` always marks the cookie Secure (the documented knob for a TLS
|
||||||
|
proxy), ``false`` never does, which is the escape hatch for an install
|
||||||
|
that still answers on plain HTTP alongside HTTPS. Anything else —
|
||||||
|
unset, or the present-but-empty value docker-compose injects for a
|
||||||
|
variable the host has not defined — derives it from the request, so an
|
||||||
|
HTTPS login gets a Secure cookie without any configuration.
|
||||||
|
|
||||||
|
Either the connection scheme or ``X-Forwarded-Proto`` saying https is
|
||||||
|
enough, which is the same test ``core/middleware.py`` applies before it
|
||||||
|
sends HSTS. Uvicorn's proxy-headers middleware already folds that header
|
||||||
|
into the scheme for the proxies it trusts, so reading it here only adds
|
||||||
|
the case of a terminator that is not on a trusted address; the cost is
|
||||||
|
that a client talking to the app directly can set the header and lock
|
||||||
|
its own session out over plain HTTP.
|
||||||
|
"""
|
||||||
|
configured = os.getenv("SECURE_COOKIES", "").strip().lower()
|
||||||
|
if configured in ("true", "false"):
|
||||||
|
return configured == "true"
|
||||||
|
# A chained proxy sends a list — the client-facing hop comes first.
|
||||||
|
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0]
|
||||||
|
return request.url.scheme == "https" or forwarded_proto.strip().lower() == "https"
|
||||||
|
|
||||||
|
|
||||||
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -159,7 +186,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
value=token,
|
value=token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
|
secure=_secure_cookie(request),
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
if body.remember:
|
if body.remember:
|
||||||
|
|||||||
+20
-5
@@ -624,6 +624,8 @@ async def build_chat_context(
|
|||||||
agent_mode: bool = False,
|
agent_mode: bool = False,
|
||||||
allow_tool_preprocessing: bool = True,
|
allow_tool_preprocessing: bool = True,
|
||||||
defer_context_shaping: bool = False,
|
defer_context_shaping: bool = False,
|
||||||
|
continuation_context_message: str | None = None,
|
||||||
|
persist_user_message: bool = True,
|
||||||
) -> ChatContext:
|
) -> ChatContext:
|
||||||
"""Build the full context (preface + messages) for an LLM call.
|
"""Build the full context (preface + messages) for an LLM call.
|
||||||
|
|
||||||
@@ -647,14 +649,14 @@ async def build_chat_context(
|
|||||||
# Add user message to history. Nobody/incognito uses a request-local
|
# Add user message to history. Nobody/incognito uses a request-local
|
||||||
# transcript store instead of session history so stale saved chats cannot
|
# transcript store instead of session history so stale saved chats cannot
|
||||||
# bleed into context and the turn is not persisted.
|
# bleed into context and the turn is not persisted.
|
||||||
if incognito:
|
if persist_user_message and incognito:
|
||||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||||
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
||||||
else:
|
elif persist_user_message:
|
||||||
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
||||||
|
|
||||||
# Fire events
|
# Fire events
|
||||||
if not incognito:
|
if persist_user_message and not incognito:
|
||||||
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
||||||
|
|
||||||
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
||||||
@@ -666,7 +668,12 @@ async def build_chat_context(
|
|||||||
getattr(chat_handler, "upload_handler", None),
|
getattr(chat_handler, "upload_handler", None),
|
||||||
getattr(sess, "owner", None),
|
getattr(sess, "owner", None),
|
||||||
)
|
)
|
||||||
casual_low_signal = _is_casual_low_signal(message)
|
context_message = (
|
||||||
|
str(continuation_context_message).strip()
|
||||||
|
if continuation_context_message
|
||||||
|
else message
|
||||||
|
)
|
||||||
|
casual_low_signal = _is_casual_low_signal(context_message)
|
||||||
|
|
||||||
# Memory enabled?
|
# Memory enabled?
|
||||||
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
||||||
@@ -703,7 +710,15 @@ async def build_chat_context(
|
|||||||
# Build context preface
|
# Build context preface
|
||||||
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
||||||
# the sync path uses text_for_context.
|
# the sync path uses text_for_context.
|
||||||
_ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context
|
_ctx_msg = (
|
||||||
|
context_message
|
||||||
|
if continuation_context_message
|
||||||
|
else (
|
||||||
|
preprocessed.enhanced_message
|
||||||
|
if use_enhanced_message
|
||||||
|
else preprocessed.text_for_context
|
||||||
|
)
|
||||||
|
)
|
||||||
_preface_kwargs = dict(
|
_preface_kwargs = dict(
|
||||||
message=_ctx_msg,
|
message=_ctx_msg,
|
||||||
session=sess,
|
session=sess,
|
||||||
|
|||||||
+140
-41
@@ -89,6 +89,65 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||||
|
"""Persist a consumed approval decision on its existing tool event."""
|
||||||
|
|
||||||
|
approval_key = str(approval_id or "")
|
||||||
|
normalized_decision = str(decision or "").strip().lower()
|
||||||
|
if not approval_key or normalized_decision not in {"approve", "approve_task", "deny"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
message_id = None
|
||||||
|
resolved_metadata = None
|
||||||
|
for item in reversed(getattr(sess, "history", []) or []):
|
||||||
|
metadata = getattr(item, "metadata", None)
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
continue
|
||||||
|
tool_events = metadata.get("tool_events")
|
||||||
|
if not isinstance(tool_events, list):
|
||||||
|
continue
|
||||||
|
for event in reversed(tool_events):
|
||||||
|
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
||||||
|
if not isinstance(ask_user, dict):
|
||||||
|
continue
|
||||||
|
if str(ask_user.get("approval_id") or "") != approval_key:
|
||||||
|
continue
|
||||||
|
ask_user["resolved"] = normalized_decision
|
||||||
|
message_id = metadata.get("_db_id")
|
||||||
|
resolved_metadata = {
|
||||||
|
key: value for key, value in metadata.items() if key != "_db_id"
|
||||||
|
}
|
||||||
|
break
|
||||||
|
if resolved_metadata is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if resolved_metadata is None or not message_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db_message = db.query(DBChatMessage).filter(
|
||||||
|
DBChatMessage.id == message_id,
|
||||||
|
DBChatMessage.session_id == str(getattr(sess, "id", "")),
|
||||||
|
).first()
|
||||||
|
if db_message is None:
|
||||||
|
return False
|
||||||
|
db_message.meta_data = json.dumps(resolved_metadata)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to persist tool approval resolution")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _tool_approval_resolution_stream(decision: str) -> AsyncGenerator[str, None]:
|
||||||
|
yield f"data: {json.dumps({'type': 'tool_approval_resolved', 'decision': decision})}\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
|
||||||
def _chat_candidate_request_factory(
|
def _chat_candidate_request_factory(
|
||||||
messages,
|
messages,
|
||||||
fallback_context_length: int = 0,
|
fallback_context_length: int = 0,
|
||||||
@@ -917,6 +976,7 @@ def setup_chat_routes(
|
|||||||
exact_tool_approval = None
|
exact_tool_approval = None
|
||||||
pending_tool_approval = None
|
pending_tool_approval = None
|
||||||
retired_tool_approval_taint = False
|
retired_tool_approval_taint = False
|
||||||
|
external_untrusted_context_seen = False
|
||||||
tool_approval_continuation = False
|
tool_approval_continuation = False
|
||||||
# Workspace: confine the agent's file/shell tools to this folder.
|
# Workspace: confine the agent's file/shell tools to this folder.
|
||||||
workspace, workspace_rejected = _resolve_request_workspace(
|
workspace, workspace_rejected = _resolve_request_workspace(
|
||||||
@@ -1050,14 +1110,14 @@ def setup_chat_routes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Attachment-only sends: skip the message-required check when the
|
# Attachment-only sends and approval controls may omit message text.
|
||||||
# user has attached one or more files (the attachment IS the action).
|
|
||||||
_has_atts = (
|
_has_atts = (
|
||||||
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
|
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
|
||||||
or bool(form_data.get("attachments"))
|
or bool(form_data.get("attachments"))
|
||||||
)
|
)
|
||||||
message, session = coerce_message_and_session(
|
message, session = coerce_message_and_session(
|
||||||
body, message, session, session_manager, allow_empty=_has_atts,
|
body, message, session, session_manager,
|
||||||
|
allow_empty=(_has_atts or bool(tool_approval_id)),
|
||||||
)
|
)
|
||||||
# Verify ownership AFTER coerce (which may resolve a default session)
|
# Verify ownership AFTER coerce (which may resolve a default session)
|
||||||
# but BEFORE loading. Prevents cross-user session hijack.
|
# but BEFORE loading. Prevents cross-user session hijack.
|
||||||
@@ -1076,8 +1136,14 @@ def setup_chat_routes(
|
|||||||
409,
|
409,
|
||||||
"This tool approval is invalid, expired, or belongs to another thread.",
|
"This tool approval is invalid, expired, or belongs to another thread.",
|
||||||
)
|
)
|
||||||
|
pending_taint = bool(
|
||||||
|
pending_tool_approval.external_untrusted_context_seen
|
||||||
|
)
|
||||||
|
external_untrusted_context_seen = (
|
||||||
|
external_untrusted_context_seen or pending_taint
|
||||||
|
)
|
||||||
decision = str(tool_approval_decision or "").strip().lower()
|
decision = str(tool_approval_decision or "").strip().lower()
|
||||||
if decision not in {"approve", "deny"}:
|
if decision not in {"approve", "approve_task", "deny"}:
|
||||||
raise HTTPException(400, "Invalid tool approval decision.")
|
raise HTTPException(400, "Invalid tool approval decision.")
|
||||||
if plan_mode:
|
if plan_mode:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1091,37 +1157,46 @@ def setup_chat_routes(
|
|||||||
session_id=session,
|
session_id=session,
|
||||||
)
|
)
|
||||||
tool_approval_continuation = True
|
tool_approval_continuation = True
|
||||||
if decision == "approve" and exact_tool_approval is None:
|
if (
|
||||||
|
decision in {"approve", "approve_task"}
|
||||||
|
and exact_tool_approval is None
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
409,
|
409,
|
||||||
"This tool approval could not be consumed.",
|
"This tool approval could not be consumed.",
|
||||||
)
|
)
|
||||||
if decision == "approve":
|
if not _mark_tool_approval_resolved(
|
||||||
message = (
|
sess,
|
||||||
f"Approved the exact {pending_tool_approval.tool_name} action "
|
tool_approval_id,
|
||||||
"shown above once."
|
decision,
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Tool approval %s was consumed but its persisted card could not be marked resolved",
|
||||||
|
tool_approval_id,
|
||||||
)
|
)
|
||||||
# The sealed server record, not mutable composer state,
|
if decision == "deny":
|
||||||
# restores the original action workspace.
|
return StreamingResponse(
|
||||||
workspace = pending_tool_approval.workspace or None
|
_tool_approval_resolution_stream(decision),
|
||||||
workspace_rejected = None
|
media_type="text/event-stream",
|
||||||
if pending_tool_approval.document_id:
|
|
||||||
active_doc_id = pending_tool_approval.document_id
|
|
||||||
# The approval click is the per-turn opt-in for this exact
|
|
||||||
# sealed action. Restore only the coarse request toggle
|
|
||||||
# that would otherwise disable it because the synthetic
|
|
||||||
# "Approved…" message no longer resembles the original
|
|
||||||
# shell/web request. Current privilege, global-disable,
|
|
||||||
# incognito, compare, and tool-policy gates still run.
|
|
||||||
if pending_tool_approval.tool_name == "bash":
|
|
||||||
allow_bash = "true"
|
|
||||||
if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
|
|
||||||
allow_web_search = "true"
|
|
||||||
_search_enabled = True
|
|
||||||
else:
|
|
||||||
message = (
|
|
||||||
f"Denied the {pending_tool_approval.tool_name} action shown above."
|
|
||||||
)
|
)
|
||||||
|
# Approval is a control-plane continuation, not a new user turn.
|
||||||
|
# Reuse the sealed interrupted request only for internal context,
|
||||||
|
# retrieval, and policy reconstruction; never persist or display it.
|
||||||
|
message = pending_tool_approval.continuation_query
|
||||||
|
# The sealed server record, not mutable composer state,
|
||||||
|
# restores the original action workspace.
|
||||||
|
workspace = pending_tool_approval.workspace or None
|
||||||
|
workspace_rejected = None
|
||||||
|
if pending_tool_approval.document_id:
|
||||||
|
active_doc_id = pending_tool_approval.document_id
|
||||||
|
# Restore only the coarse request toggle needed by the exact
|
||||||
|
# sealed action. Current privilege, global-disable, incognito,
|
||||||
|
# compare, and tool-policy gates still run.
|
||||||
|
if pending_tool_approval.tool_name == "bash":
|
||||||
|
allow_bash = "true"
|
||||||
|
if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
|
||||||
|
allow_web_search = "true"
|
||||||
|
_search_enabled = True
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
else:
|
else:
|
||||||
# A normal user message supersedes the card that was waiting
|
# A normal user message supersedes the card that was waiting
|
||||||
@@ -1132,6 +1207,9 @@ def setup_chat_routes(
|
|||||||
owner=owner,
|
owner=owner,
|
||||||
session_id=session,
|
session_id=session,
|
||||||
)
|
)
|
||||||
|
external_untrusted_context_seen = (
|
||||||
|
external_untrusted_context_seen or retired_tool_approval_taint
|
||||||
|
)
|
||||||
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
||||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||||
@@ -1261,6 +1339,14 @@ def setup_chat_routes(
|
|||||||
agent_mode=(chat_mode == "agent"),
|
agent_mode=(chat_mode == "agent"),
|
||||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||||
defer_context_shaping=foreground_policy.enabled,
|
defer_context_shaping=foreground_policy.enabled,
|
||||||
|
continuation_context_message=(
|
||||||
|
pending_tool_approval.continuation_query
|
||||||
|
if exact_tool_approval
|
||||||
|
and pending_tool_approval
|
||||||
|
and pending_tool_approval.continuation_query
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
persist_user_message=not tool_approval_continuation,
|
||||||
)
|
)
|
||||||
|
|
||||||
_research_flags = {"do": do_research} # Mutable container for generator scope
|
_research_flags = {"do": do_research} # Mutable container for generator scope
|
||||||
@@ -1663,7 +1749,11 @@ def setup_chat_routes(
|
|||||||
if foreground_policy.enabled
|
if foreground_policy.enabled
|
||||||
else ctx.messages
|
else ctx.messages
|
||||||
)
|
)
|
||||||
messages = _ensure_current_request_is_latest_user(context_source, message)
|
messages = (
|
||||||
|
list(context_source)
|
||||||
|
if tool_approval_continuation
|
||||||
|
else _ensure_current_request_is_latest_user(context_source, message)
|
||||||
|
)
|
||||||
|
|
||||||
# Auto-compact notification
|
# Auto-compact notification
|
||||||
if ctx.was_compacted:
|
if ctx.was_compacted:
|
||||||
@@ -2134,7 +2224,10 @@ def setup_chat_routes(
|
|||||||
incognito=incognito, compare_mode=compare_mode,
|
incognito=incognito, compare_mode=compare_mode,
|
||||||
character_name=ctx.preset.character_name,
|
character_name=ctx.preset.character_name,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
allow_background_extraction=(
|
||||||
|
not tool_policy.block_all_tool_calls
|
||||||
|
and not tool_approval_continuation
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_stream_set(session, status="done")
|
_stream_set(session, status="done")
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -2223,17 +2316,17 @@ def setup_chat_routes(
|
|||||||
plan_mode=plan_mode,
|
plan_mode=plan_mode,
|
||||||
approved_plan=approved_plan or None,
|
approved_plan=approved_plan or None,
|
||||||
workspace=workspace or None,
|
workspace=workspace or None,
|
||||||
|
relevant_tools=(
|
||||||
|
set(pending_tool_approval.selected_tools)
|
||||||
|
if exact_tool_approval
|
||||||
|
and pending_tool_approval
|
||||||
|
and pending_tool_approval.selected_tools
|
||||||
|
else None
|
||||||
|
),
|
||||||
forced_tools=_forced_tools,
|
forced_tools=_forced_tools,
|
||||||
uploaded_files=ctx.uploaded_files,
|
uploaded_files=ctx.uploaded_files,
|
||||||
defer_context_shaping=_foreground_policy.enabled,
|
defer_context_shaping=_foreground_policy.enabled,
|
||||||
external_untrusted_context_seen=bool(
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
retired_tool_approval_taint
|
|
||||||
or (
|
|
||||||
tool_approval_continuation
|
|
||||||
and pending_tool_approval
|
|
||||||
and pending_tool_approval.external_untrusted_context_seen
|
|
||||||
)
|
|
||||||
),
|
|
||||||
exact_approval=exact_tool_approval,
|
exact_approval=exact_tool_approval,
|
||||||
):
|
):
|
||||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
@@ -2401,8 +2494,14 @@ def setup_chat_routes(
|
|||||||
agent_tool_calls=_agent_tool_calls,
|
agent_tool_calls=_agent_tool_calls,
|
||||||
skills_manager=skills_manager,
|
skills_manager=skills_manager,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
extract_skills=user_requested_agent,
|
extract_skills=(
|
||||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
user_requested_agent
|
||||||
|
and not tool_approval_continuation
|
||||||
|
),
|
||||||
|
allow_background_extraction=(
|
||||||
|
not tool_policy.block_all_tool_calls
|
||||||
|
and not tool_approval_continuation
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_stream_set(session, status="done")
|
_stream_set(session, status="done")
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|||||||
@@ -1204,6 +1204,41 @@ def _safe_env_prefix(ep: str | None) -> str | None:
|
|||||||
return f'[ -f "{path}" ] && source "{path}" || true'
|
return f'[ -f "{path}" ] && source "{path}" || true'
|
||||||
|
|
||||||
|
|
||||||
|
def _local_windows_bash_env_prefix(ep: str | None) -> str | None:
|
||||||
|
"""Convert a frontend PowerShell venv prefix for the local Git Bash runner."""
|
||||||
|
if not ep:
|
||||||
|
return ep
|
||||||
|
|
||||||
|
prefix = ep.strip()
|
||||||
|
if not prefix.startswith("&"):
|
||||||
|
return ep
|
||||||
|
|
||||||
|
raw_path = prefix[1:].lstrip()
|
||||||
|
if not raw_path:
|
||||||
|
return ep
|
||||||
|
if raw_path.startswith("'"):
|
||||||
|
if len(raw_path) < 2 or not raw_path.endswith("'"):
|
||||||
|
return ep
|
||||||
|
quoted_path = raw_path[1:-1]
|
||||||
|
if "'" in quoted_path.replace("''", ""):
|
||||||
|
return ep
|
||||||
|
path = quoted_path.replace("''", "'")
|
||||||
|
else:
|
||||||
|
path = raw_path.rstrip()
|
||||||
|
if "'" in path or '"' in path:
|
||||||
|
return ep
|
||||||
|
if any(c in path for c in "\r\n;&|`$<>"):
|
||||||
|
return ep
|
||||||
|
if not path.replace("\\", "/").casefold().endswith("/scripts/activate.ps1"):
|
||||||
|
return ep
|
||||||
|
|
||||||
|
bash_path = _git_bash_path(path)
|
||||||
|
if "\\" in bash_path:
|
||||||
|
return ep
|
||||||
|
bash_path = bash_path[: -len("Activate.ps1")] + "activate"
|
||||||
|
return "source " + shlex.quote(bash_path)
|
||||||
|
|
||||||
|
|
||||||
def _ssh_ps(host, script_path, port=None):
|
def _ssh_ps(host, script_path, port=None):
|
||||||
"""Build SSH command to run a PowerShell script on a Windows remote."""
|
"""Build SSH command to run a PowerShell script on a Windows remote."""
|
||||||
pf = f"-p {port} " if port and port != "22" else ""
|
pf = f"-p {port} " if port and port != "22" else ""
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ from routes.cookbook_helpers import (
|
|||||||
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
|
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
|
||||||
_validate_local_dir, _validate_gpus, _shell_path,
|
_validate_local_dir, _validate_gpus, _shell_path,
|
||||||
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT,
|
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT,
|
||||||
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
_safe_env_prefix, _local_windows_bash_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||||
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
|
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
|
||||||
load_stored_hf_token,
|
load_stored_hf_token,
|
||||||
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
|
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
|
||||||
@@ -1336,7 +1336,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# Local: run hf download in the background (tmux on POSIX, a detached
|
# Local: run hf download in the background (tmux on POSIX, a detached
|
||||||
# process + logfile on Windows where tmux doesn't exist).
|
# process + logfile on Windows where tmux doesn't exist).
|
||||||
if req.env_prefix:
|
if req.env_prefix:
|
||||||
lines.append(_safe_env_prefix(req.env_prefix))
|
lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
|
||||||
else:
|
else:
|
||||||
lines.append("deactivate 2>/dev/null; hash -r")
|
lines.append("deactivate 2>/dev/null; hash -r")
|
||||||
# Show whether the HF token reached this run (masked) — tells a gated
|
# Show whether the HF token reached this run (masked) — tells a gated
|
||||||
@@ -2166,7 +2166,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
if req.gpus:
|
if req.gpus:
|
||||||
runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'")
|
runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'")
|
||||||
if req.env_prefix:
|
if req.env_prefix:
|
||||||
runner_lines.append(_safe_env_prefix(req.env_prefix))
|
runner_lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
|
||||||
else:
|
else:
|
||||||
runner_lines.append("deactivate 2>/dev/null; hash -r")
|
runner_lines.append("deactivate 2>/dev/null; hash -r")
|
||||||
_append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd)
|
_append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd)
|
||||||
|
|||||||
@@ -1351,14 +1351,14 @@ def _legacy_visible_api_models(ep) -> List[str]:
|
|||||||
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
|
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
|
||||||
"""Return model IDs that should appear in the picker for an endpoint.
|
"""Return model IDs that should appear in the picker for an endpoint.
|
||||||
|
|
||||||
API providers expose remote inventory from /v1/models. Treat that cache as
|
API providers expose remote inventory from /v1/models. Default to that
|
||||||
inventory, not approval: only manually pinned API models should appear in
|
visible inventory until an explicit pinned-model allow-list is saved.
|
||||||
the picker. Local/self-hosted endpoints keep the older hide-list behavior.
|
Local/self-hosted endpoints keep the older hide-list behavior.
|
||||||
"""
|
"""
|
||||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||||
if _picker_requires_pinning(base_url, kind):
|
if _picker_requires_pinning(base_url, kind):
|
||||||
if not _has_explicit_pinned_models(ep):
|
if not _has_explicit_pinned_models(ep):
|
||||||
pinned = _legacy_visible_api_models(ep) if _hidden_model_ids(ep) else []
|
pinned = _legacy_visible_api_models(ep)
|
||||||
return pinned, pinned
|
return pinned, pinned
|
||||||
return _visible_models(
|
return _visible_models(
|
||||||
_cached_model_ids(ep),
|
_cached_model_ids(ep),
|
||||||
@@ -2342,9 +2342,7 @@ def setup_model_routes(model_discovery):
|
|||||||
else:
|
else:
|
||||||
response.headers["X-Model-Refresh-Status"] = "failed"
|
response.headers["X-Model-Refresh-Status"] = "failed"
|
||||||
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
|
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
|
||||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
_, pinned = _picker_models_for_endpoint(ep, base, kind)
|
||||||
if picker_requires_pinning and not _has_explicit_pinned_models(ep):
|
|
||||||
pinned = _legacy_visible_api_models(ep)
|
|
||||||
pinned_set = set(pinned)
|
pinned_set = set(pinned)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1603,6 +1603,9 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
decision=decision,
|
decision=decision,
|
||||||
owner=user,
|
owner=user,
|
||||||
session_id=None,
|
session_id=None,
|
||||||
|
# The button here says "Allow once" and there is no chat to carry a
|
||||||
|
# scope into, so the gate must re-arm behind the sealed action.
|
||||||
|
allow_continuation=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if decision == "approve" and exact_approval is None:
|
if decision == "approve" and exact_approval is None:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Task route domain package (slice 2p, #4082/#4071).
|
||||||
|
|
||||||
|
Contains task_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/task_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
File diff suppressed because it is too large
Load Diff
+14
-1177
File diff suppressed because it is too large
Load Diff
@@ -327,7 +327,12 @@ def list_models():
|
|||||||
|
|
||||||
@app.post("/v1/images/generations")
|
@app.post("/v1/images/generations")
|
||||||
def generate(req: ImageRequest):
|
def generate(req: ImageRequest):
|
||||||
model = req.model or _args.model
|
# The served model is the one this process was launched with. `req.model`
|
||||||
|
# is accepted for OpenAI wire compatibility and ignored, matching
|
||||||
|
# scripts/diffusion_server.py: honouring it would let a caller point the
|
||||||
|
# generator at any local directory or Hugging Face repo, and the HiDream
|
||||||
|
# branch runs a python script from inside that directory.
|
||||||
|
model = _args.model
|
||||||
width, height = _size(req.size)
|
width, height = _size(req.size)
|
||||||
out_images = []
|
out_images = []
|
||||||
count = max(1, min(int(req.n or 1), 4))
|
count = max(1, min(int(req.n or 1), 4))
|
||||||
@@ -393,7 +398,7 @@ async def edit_image(
|
|||||||
size: str = Form("1024x1024"),
|
size: str = Form("1024x1024"),
|
||||||
response_format: str = Form("b64_json"),
|
response_format: str = Form("b64_json"),
|
||||||
):
|
):
|
||||||
active_model = model or _args.model
|
active_model = _args.model # pinned; see generate()
|
||||||
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
||||||
image_raw = await image.read()
|
image_raw = await image.read()
|
||||||
mask_raw = await mask.read() if mask is not None else None
|
mask_raw = await mask.read() if mask is not None else None
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""odysseus-webhook — shell wrapper for scheduled-task webhook tokens.
|
"""odysseus-webhook — shell wrapper for scheduled-task webhook tokens.
|
||||||
|
|
||||||
Tasks in the scheduled-task system can carry a `webhook_token`. Any
|
Tasks in the scheduled-task system can carry a `webhook_token`. Any
|
||||||
HTTP POST to `/api/webhook/<token>` fires the task. This CLI lists,
|
HTTP POST to `/api/tasks/<task-id>/webhook/<token>` fires the task. This CLI lists,
|
||||||
rotates, and revokes those tokens.
|
rotates, and revokes those tokens.
|
||||||
|
|
||||||
odysseus-webhook list # tasks that have a token
|
odysseus-webhook list # tasks that have a token
|
||||||
@@ -21,6 +21,7 @@ quiet_logs()
|
|||||||
|
|
||||||
import argparse, json, logging, os, secrets, sys
|
import argparse, json, logging, os, secrets, sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from core.database import SessionLocal, ScheduledTask
|
from core.database import SessionLocal, ScheduledTask
|
||||||
@@ -53,6 +54,14 @@ def _summary(t: "ScheduledTask", reveal: bool = False) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _task_webhook_url(base: str, task_id: str, token: str) -> str:
|
||||||
|
"""Build the live task-route URL without leaking ids into path syntax."""
|
||||||
|
root = (base or "http://localhost:7000").rstrip("/")
|
||||||
|
task_part = quote(str(task_id), safe="")
|
||||||
|
token_part = quote(str(token), safe="")
|
||||||
|
return f"{root}/api/tasks/{task_part}/webhook/{token_part}"
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(args):
|
def cmd_list(args):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -109,8 +118,7 @@ def cmd_url(args):
|
|||||||
fail(f"no task with id {args.id!r}")
|
fail(f"no task with id {args.id!r}")
|
||||||
if not t.webhook_token:
|
if not t.webhook_token:
|
||||||
fail(f"task {args.id!r} has no webhook token (rotate one first)")
|
fail(f"task {args.id!r} has no webhook token (rotate one first)")
|
||||||
base = (args.base or "http://localhost:7000").rstrip("/")
|
url = _task_webhook_url(args.base, t.id, t.webhook_token)
|
||||||
url = f"{base}/api/webhook/{t.webhook_token}"
|
|
||||||
emit({
|
emit({
|
||||||
"task_id": t.id,
|
"task_id": t.id,
|
||||||
"name": t.name,
|
"name": t.name,
|
||||||
|
|||||||
+41
-11
@@ -50,16 +50,46 @@ class DocsService:
|
|||||||
List of DocChunk objects
|
List of DocChunk objects
|
||||||
"""
|
"""
|
||||||
results = self.rag.search(query, k=top_k)
|
results = self.rag.search(query, k=top_k)
|
||||||
return [
|
chunks = []
|
||||||
DocChunk(
|
|
||||||
text=r.get("text", r.get("content", "")),
|
for result in results:
|
||||||
source=r.get("source", r.get("metadata", {}).get("source", "unknown")),
|
if not isinstance(result, dict):
|
||||||
score=r.get("score", 0.0),
|
continue
|
||||||
metadata=r.get("metadata"),
|
|
||||||
|
metadata = result.get("metadata")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
text = result.get("document")
|
||||||
|
if text is None:
|
||||||
|
text = result.get("text")
|
||||||
|
if text is None:
|
||||||
|
text = result.get("content")
|
||||||
|
if text is None:
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
source = result.get("source")
|
||||||
|
if source is None:
|
||||||
|
source = metadata.get("source")
|
||||||
|
if source is None:
|
||||||
|
source = "unknown"
|
||||||
|
|
||||||
|
score = result.get("similarity")
|
||||||
|
if score is None:
|
||||||
|
score = result.get("score")
|
||||||
|
if score is None:
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
chunks.append(
|
||||||
|
DocChunk(
|
||||||
|
text=text,
|
||||||
|
source=source,
|
||||||
|
score=score,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for r in results
|
|
||||||
if isinstance(r, dict)
|
return chunks
|
||||||
]
|
|
||||||
|
|
||||||
async def index(self, directory: str) -> IndexResult:
|
async def index(self, directory: str) -> IndexResult:
|
||||||
"""
|
"""
|
||||||
@@ -73,8 +103,8 @@ class DocsService:
|
|||||||
"""
|
"""
|
||||||
result = self.rag.index_personal_documents(directory)
|
result = self.rag.index_personal_documents(directory)
|
||||||
return IndexResult(
|
return IndexResult(
|
||||||
indexed=result.get("indexed", 0),
|
indexed=result.get("indexed_count", result.get("indexed", 0)),
|
||||||
failed=result.get("failed", 0),
|
failed=result.get("failed_count", result.get("failed", 0)),
|
||||||
errors=result.get("errors", []),
|
errors=result.get("errors", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+31
-331
@@ -2,22 +2,18 @@
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import io
|
import io
|
||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
import socket
|
|
||||||
import ssl
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Iterable, List, cast
|
from typing import List
|
||||||
from urllib.parse import urljoin, urlparse
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import httpcore
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
|
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
|
||||||
|
from src import outbound_fetch as _outbound_fetch
|
||||||
|
|
||||||
from .analytics import RateLimitError, error_logger
|
from .analytics import RateLimitError, error_logger
|
||||||
from .cache import (
|
from .cache import (
|
||||||
@@ -29,336 +25,40 @@ from .cache import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_PRIVATE_NETWORKS = (
|
def _is_private_address(addr):
|
||||||
ipaddress.ip_network("0.0.0.0/8"),
|
return _outbound_fetch._is_private_address(addr)
|
||||||
ipaddress.ip_network("10.0.0.0/8"),
|
|
||||||
ipaddress.ip_network("127.0.0.0/8"),
|
|
||||||
ipaddress.ip_network("169.254.0.0/16"),
|
|
||||||
ipaddress.ip_network("172.16.0.0/12"),
|
|
||||||
ipaddress.ip_network("192.168.0.0/16"),
|
|
||||||
ipaddress.ip_network("::1/128"),
|
|
||||||
ipaddress.ip_network("fc00::/7"),
|
|
||||||
ipaddress.ip_network("fe80::/10"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
|
def _resolve_hostname_ips(hostname):
|
||||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
return _outbound_fetch._resolve_hostname_ips(hostname)
|
||||||
addr = addr.ipv4_mapped
|
|
||||||
return (
|
|
||||||
addr.is_private
|
def _public_http_url(url):
|
||||||
or addr.is_loopback
|
return _outbound_fetch._public_http_url(url, resolver=_resolve_hostname_ips)
|
||||||
or addr.is_link_local
|
|
||||||
or addr.is_reserved
|
|
||||||
or addr.is_multicast
|
def _resolve_public_ips(url):
|
||||||
or addr.is_unspecified
|
return _outbound_fetch._resolve_public_ips(url, resolver=_resolve_hostname_ips)
|
||||||
or any(addr in net for net in _PRIVATE_NETWORKS)
|
|
||||||
|
|
||||||
|
_PinnedBackend = _outbound_fetch._PinnedBackend
|
||||||
|
_PinnedTransport = _outbound_fetch._PinnedTransport
|
||||||
|
BodyTooLargeError = _outbound_fetch.BodyTooLargeError
|
||||||
|
_CappedFetch = _outbound_fetch._CappedFetch
|
||||||
|
|
||||||
|
|
||||||
|
def _get_public_url(url, headers, timeout, max_redirects=5, max_bytes=None):
|
||||||
|
return _outbound_fetch._get_public_url(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
max_redirects=max_redirects,
|
||||||
|
max_bytes=max_bytes,
|
||||||
|
resolve_public_ips=_resolve_public_ips,
|
||||||
|
transport_factory=_PinnedTransport,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
|
|
||||||
try:
|
|
||||||
infos = socket.getaddrinfo(hostname, None)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
out = []
|
|
||||||
for info in infos:
|
|
||||||
try:
|
|
||||||
out.append(ipaddress.ip_address(info[4][0]))
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _public_http_url(url: str) -> bool:
|
|
||||||
try:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
|
||||||
return False
|
|
||||||
host = (parsed.hostname or "").strip()
|
|
||||||
if not host:
|
|
||||||
return False
|
|
||||||
lower = host.lower()
|
|
||||||
if lower in ("localhost", "metadata", "metadata.google.internal"):
|
|
||||||
return False
|
|
||||||
if lower.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return not _is_private_address(ipaddress.ip_address(host))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
addrs = _resolve_hostname_ips(host)
|
|
||||||
return bool(addrs) and not any(_is_private_address(a) for a in addrs)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_public_ips(url: str) -> list[ipaddress._BaseAddress]:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
||||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
|
||||||
host = (parsed.hostname or "").strip().lower()
|
|
||||||
if host in ("localhost", "metadata", "metadata.google.internal"):
|
|
||||||
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
|
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(host)
|
|
||||||
if _is_private_address(ip):
|
|
||||||
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
|
|
||||||
return [ip]
|
|
||||||
except httpx.RequestError:
|
|
||||||
raise
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
addrs = _resolve_hostname_ips(host)
|
|
||||||
if not addrs or any(_is_private_address(a) for a in addrs):
|
|
||||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
|
||||||
return addrs
|
|
||||||
|
|
||||||
|
|
||||||
class _PinnedBackend(httpcore.NetworkBackend):
|
|
||||||
"""Network backend that connects to a pre-resolved IP.
|
|
||||||
|
|
||||||
httpcore derives the TLS SNI and the ``Host`` header from the URL's
|
|
||||||
origin, not from the host argument passed to ``connect_tcp``. So
|
|
||||||
routing the TCP connect to a resolved IP while leaving the URL
|
|
||||||
untouched keeps SNI / vhost behaviour correct and closes the
|
|
||||||
DNS-rebinding TOCTOU between the SSRF check and the connect.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ip: ipaddress._BaseAddress):
|
|
||||||
self._ip = str(ip)
|
|
||||||
self._real = httpcore.SyncBackend()
|
|
||||||
|
|
||||||
def connect_tcp(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
timeout: float | None = None,
|
|
||||||
local_address: str | None = None,
|
|
||||||
socket_options=None,
|
|
||||||
):
|
|
||||||
return self._real.connect_tcp(
|
|
||||||
self._ip, port, timeout, local_address, socket_options
|
|
||||||
)
|
|
||||||
|
|
||||||
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
|
||||||
return self._real.connect_unix_socket(path, timeout, socket_options)
|
|
||||||
|
|
||||||
def sleep(self, seconds: float) -> None:
|
|
||||||
return self._real.sleep(seconds)
|
|
||||||
|
|
||||||
|
|
||||||
# Map httpcore exception classes to their httpx equivalents. Built
|
|
||||||
# once at import time from the public exception classes; avoids any
|
|
||||||
# import of httpx's private transport machinery. httpcore's
|
|
||||||
# ``ConnectionNotAvailable`` is a pool-internal signal (the pool will
|
|
||||||
# close and retry on its own) — we never expect to see it surface to
|
|
||||||
# a transport caller, so it has no httpx counterpart here.
|
|
||||||
_HTTPCORE_TO_HTTPX_EXC = {
|
|
||||||
httpcore.ConnectError: httpx.ConnectError,
|
|
||||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
|
||||||
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
|
||||||
httpcore.NetworkError: httpx.NetworkError,
|
|
||||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
|
||||||
httpcore.ProtocolError: httpx.ProtocolError,
|
|
||||||
httpcore.ProxyError: httpx.ProxyError,
|
|
||||||
httpcore.ReadError: httpx.ReadError,
|
|
||||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
|
||||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
|
||||||
httpcore.TimeoutException: httpx.TimeoutException,
|
|
||||||
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
|
||||||
httpcore.WriteError: httpx.WriteError,
|
|
||||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _PinnedTransport(httpx.BaseTransport):
|
|
||||||
"""Transport that pins every TCP connect to a pre-resolved IP.
|
|
||||||
|
|
||||||
Uses only the public ``httpcore`` and ``httpx`` APIs — no
|
|
||||||
subclassing of ``httpx.HTTPTransport``, no reads of private
|
|
||||||
``httpcore.ConnectionPool`` attributes, no imports from
|
|
||||||
``httpx private transport internals``. The URL is passed through unchanged so SNI
|
|
||||||
/ vhost work as if httpx had been given the hostname directly;
|
|
||||||
only the TCP destination is pinned, closing the DNS-rebinding
|
|
||||||
TOCTOU between the SSRF check and the connect.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
|
|
||||||
self._pool = httpcore.ConnectionPool(
|
|
||||||
ssl_context=ssl.create_default_context(),
|
|
||||||
http1=True,
|
|
||||||
http2=http2,
|
|
||||||
network_backend=_PinnedBackend(ip),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self._pool.__enter__()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
|
|
||||||
self._pool.__exit__(exc_type, exc_value, traceback)
|
|
||||||
|
|
||||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
||||||
httpcore_req = httpcore.Request(
|
|
||||||
method=request.method,
|
|
||||||
url=httpcore.URL(
|
|
||||||
scheme=request.url.raw_scheme,
|
|
||||||
host=request.url.raw_host,
|
|
||||||
port=request.url.port,
|
|
||||||
target=request.url.raw_path,
|
|
||||||
),
|
|
||||||
headers=request.headers.raw,
|
|
||||||
content=request.stream,
|
|
||||||
extensions=request.extensions,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
httpcore_resp = self._pool.handle_request(httpcore_req)
|
|
||||||
# Eager materialisation matches the original
|
|
||||||
# ``response.text`` usage in fetch_webpage_content. The
|
|
||||||
# sync pool's stream is a plain Iterable[bytes] despite
|
|
||||||
# the httpcore type hint unioning the async variant.
|
|
||||||
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
|
|
||||||
except Exception as exc:
|
|
||||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
|
||||||
if mapped is not None:
|
|
||||||
raise mapped(str(exc)) from exc
|
|
||||||
raise
|
|
||||||
|
|
||||||
return httpx.Response(
|
|
||||||
status_code=httpcore_resp.status,
|
|
||||||
headers=httpcore_resp.headers,
|
|
||||||
content=content,
|
|
||||||
extensions=httpcore_resp.extensions,
|
|
||||||
)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self._pool.close()
|
|
||||||
|
|
||||||
class BodyTooLargeError(Exception):
|
|
||||||
"""The server declared a body larger than the hard fetch ceiling."""
|
|
||||||
|
|
||||||
def __init__(self, url: str, declared_bytes: int):
|
|
||||||
self.url = url
|
|
||||||
self.declared_bytes = declared_bytes
|
|
||||||
super().__init__(
|
|
||||||
f"response body is {declared_bytes:,} bytes, over the "
|
|
||||||
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _CappedFetch:
|
|
||||||
"""Result of a size-capped streaming GET.
|
|
||||||
|
|
||||||
Carries just what fetch_webpage_content needs from an httpx.Response,
|
|
||||||
plus the cap bookkeeping: the (possibly truncated) body, whether the
|
|
||||||
cap cut it short, and the size the server declared via Content-Length
|
|
||||||
(wire bytes; None when absent).
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("status_code", "headers", "content", "truncated",
|
|
||||||
"declared_bytes", "encoding", "url")
|
|
||||||
|
|
||||||
def __init__(self, status_code, headers, content, truncated,
|
|
||||||
declared_bytes, encoding, url):
|
|
||||||
self.status_code = status_code
|
|
||||||
self.headers = headers
|
|
||||||
self.content = content
|
|
||||||
self.truncated = truncated
|
|
||||||
self.declared_bytes = declared_bytes
|
|
||||||
self.encoding = encoding
|
|
||||||
self.url = url
|
|
||||||
|
|
||||||
@property
|
|
||||||
def text(self) -> str:
|
|
||||||
return self.content.decode(self.encoding or "utf-8", errors="replace")
|
|
||||||
|
|
||||||
def raise_for_status(self):
|
|
||||||
if self.status_code >= 400:
|
|
||||||
request = httpx.Request("GET", self.url)
|
|
||||||
raise httpx.HTTPStatusError(
|
|
||||||
f"HTTP {self.status_code} for {self.url}",
|
|
||||||
request=request,
|
|
||||||
response=httpx.Response(self.status_code, request=request),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5,
|
|
||||||
max_bytes: int = None) -> "_CappedFetch":
|
|
||||||
"""Capped streaming GET with SSRF-guarded, DNS-pinned manual redirects.
|
|
||||||
|
|
||||||
Each hop is resolved once, validated as public, and then the actual TCP
|
|
||||||
connection is pinned to that resolved IP. The request URL is left unchanged
|
|
||||||
so Host and TLS SNI keep the original hostname.
|
|
||||||
"""
|
|
||||||
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
|
||||||
current = url
|
|
||||||
for _ in range(max_redirects + 1):
|
|
||||||
ips = _resolve_public_ips(current)
|
|
||||||
|
|
||||||
# Force identity transfer-encoding. With gzip/deflate the wire bytes
|
|
||||||
# and Content-Length can be a small fraction of the decoded body, so a
|
|
||||||
# tiny compressed response could pass the hard-cap preflight and then
|
|
||||||
# expand past the ceiling in one decoded chunk before the streamed cap
|
|
||||||
# below can slice it.
|
|
||||||
req_headers = dict(headers or {})
|
|
||||||
req_headers["Accept-Encoding"] = "identity"
|
|
||||||
|
|
||||||
with httpx.Client(
|
|
||||||
headers=req_headers,
|
|
||||||
timeout=timeout,
|
|
||||||
follow_redirects=False,
|
|
||||||
transport=_PinnedTransport(ips[0]),
|
|
||||||
) as client:
|
|
||||||
with client.stream("GET", current) as response:
|
|
||||||
if response.status_code in (301, 302, 303, 307, 308):
|
|
||||||
location = response.headers.get("location")
|
|
||||||
if not location:
|
|
||||||
return _CappedFetch(response.status_code, response.headers, b"",
|
|
||||||
False, None, response.encoding, str(response.url))
|
|
||||||
current = urljoin(str(response.url), location)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# A server can ignore the identity request and still return a
|
|
||||||
# compressed body; httpx.iter_bytes would then decode it, and a
|
|
||||||
# tiny gzip can balloon into one decoded chunk far past the cap.
|
|
||||||
# Refuse compressed Content-Encoding so the streamed cap stays
|
|
||||||
# a real memory bound.
|
|
||||||
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
|
||||||
if enc and enc != "identity":
|
|
||||||
raise httpx.RequestError(
|
|
||||||
f"Refusing compressed response (Content-Encoding: {enc}) after "
|
|
||||||
"requesting identity: cannot bound decoded body size",
|
|
||||||
request=httpx.Request("GET", current),
|
|
||||||
)
|
|
||||||
|
|
||||||
declared = None
|
|
||||||
raw_len = response.headers.get("content-length")
|
|
||||||
if raw_len and raw_len.isdigit():
|
|
||||||
declared = int(raw_len)
|
|
||||||
|
|
||||||
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
|
||||||
raise BodyTooLargeError(current, declared)
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
read = 0
|
|
||||||
truncated = False
|
|
||||||
for chunk in response.iter_bytes():
|
|
||||||
read += len(chunk)
|
|
||||||
if read > cap:
|
|
||||||
keep = cap - (read - len(chunk))
|
|
||||||
if keep > 0:
|
|
||||||
chunks.append(chunk[:keep])
|
|
||||||
truncated = True
|
|
||||||
break
|
|
||||||
chunks.append(chunk)
|
|
||||||
|
|
||||||
return _CappedFetch(response.status_code, response.headers,
|
|
||||||
b"".join(chunks), truncated, declared,
|
|
||||||
response.encoding, str(response.url))
|
|
||||||
|
|
||||||
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
|
|
||||||
|
|
||||||
# PDF extraction (optional dependency)
|
# PDF extraction (optional dependency)
|
||||||
try:
|
try:
|
||||||
from pdfminer.high_level import extract_text as pdf_extract_text
|
from pdfminer.high_level import extract_text as pdf_extract_text
|
||||||
|
|||||||
+27
-5
@@ -3081,10 +3081,17 @@ def _append_tool_results(
|
|||||||
messages.append(result_message)
|
messages.append(result_message)
|
||||||
else:
|
else:
|
||||||
tool_output_text = "\n\n".join(tool_results)
|
tool_output_text = "\n\n".join(tool_results)
|
||||||
msg = {"role": "assistant", "content": round_response}
|
# An approved-action replay injects the sealed tool result with no
|
||||||
if round_reasoning:
|
# assistant prose for that round, which used to append an assistant turn
|
||||||
msg["reasoning_content"] = round_reasoning
|
# whose content was "". Anthropic's Messages API rejects a non-final
|
||||||
messages.append(msg)
|
# assistant message with empty content (HTTP 400), so the resumed turn
|
||||||
|
# died before the model saw the result. A turn carrying neither prose nor
|
||||||
|
# reasoning has nothing to say to any provider, so skip it entirely.
|
||||||
|
if round_response.strip() or round_reasoning:
|
||||||
|
msg = {"role": "assistant", "content": round_response}
|
||||||
|
if round_reasoning:
|
||||||
|
msg["reasoning_content"] = round_reasoning
|
||||||
|
messages.append(msg)
|
||||||
# Tool output (shell/python stdout, file reads, fetched pages, email
|
# Tool output (shell/python stdout, file reads, fetched pages, email
|
||||||
# bodies, MCP results) is sourced from outside the server. Wrap it as
|
# bodies, MCP results) is sourced from outside the server. Wrap it as
|
||||||
# untrusted data so prompt-injection inside a tool result is treated as
|
# untrusted data so prompt-injection inside a tool result is treated as
|
||||||
@@ -3460,7 +3467,10 @@ async def stream_agent_loop(
|
|||||||
and exact_approval.pending.external_untrusted_context_seen
|
and exact_approval.pending.external_untrusted_context_seen
|
||||||
)
|
)
|
||||||
or messages_contain_external_untrusted_context(messages)
|
or messages_contain_external_untrusted_context(messages)
|
||||||
)
|
),
|
||||||
|
approval_gate_bypassed=bool(
|
||||||
|
exact_approval and exact_approval.allow_remaining_actions
|
||||||
|
),
|
||||||
)
|
)
|
||||||
mcp_mgr = get_mcp_manager()
|
mcp_mgr = get_mcp_manager()
|
||||||
prep_timings: Dict[str, float] = {}
|
prep_timings: Dict[str, float] = {}
|
||||||
@@ -5698,6 +5708,16 @@ async def stream_agent_loop(
|
|||||||
"policy": "exact_tool_approval_target",
|
"policy": "exact_tool_approval_target",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
# The approval click becomes a synthetic user turn. Seal the
|
||||||
|
# actual server-selected candidates now so that continuation
|
||||||
|
# does not lose memory, skills, MCP, documents, or other
|
||||||
|
# ToolIndex/RAG-selected tools by classifying that synthetic text.
|
||||||
|
approval_selected_tools = set(_relevant_tools or ())
|
||||||
|
approval_selected_tools.update(
|
||||||
|
name for name in _tool_names_sent if name
|
||||||
|
)
|
||||||
|
approval_selected_tools.add(block.tool_type)
|
||||||
|
approval_selected_tools.difference_update(disabled_tools)
|
||||||
pending_approval = tool_approval_store.create(
|
pending_approval = tool_approval_store.create(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -5725,6 +5745,8 @@ async def stream_agent_loop(
|
|||||||
external_untrusted_context_seen=(
|
external_untrusted_context_seen=(
|
||||||
run_security.external_untrusted_context_seen
|
run_security.external_untrusted_context_seen
|
||||||
),
|
),
|
||||||
|
selected_tools=approval_selected_tools,
|
||||||
|
continuation_query=_retrieval_query or _last_user,
|
||||||
capabilities=capabilities_for_action(
|
capabilities=capabilities_for_action(
|
||||||
block.tool_type,
|
block.tool_type,
|
||||||
block.content,
|
block.content,
|
||||||
|
|||||||
+26
-1
@@ -462,7 +462,14 @@ class ChatProcessor:
|
|||||||
skip_url_fetch = len(message) > 2000 or len(non_yt_urls) > 3
|
skip_url_fetch = len(message) > 2000 or len(non_yt_urls) > 3
|
||||||
if not skip_url_fetch:
|
if not skip_url_fetch:
|
||||||
for url in non_yt_urls:
|
for url in non_yt_urls:
|
||||||
result = fetch_webpage_content(url)
|
try:
|
||||||
|
result = fetch_webpage_content(url)
|
||||||
|
except Exception:
|
||||||
|
# The URL and exception can both contain signed-query
|
||||||
|
# credentials or response-controlled text. Keep the log
|
||||||
|
# diagnostic stable as well as the model-facing context.
|
||||||
|
logger.warning("Automatic URL fetch failed while building context")
|
||||||
|
result = {"success": False, "error": ""}
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
content = result.get('content', '')[:10000]
|
content = result.get('content', '')[:10000]
|
||||||
preface.append(untrusted_context_message(
|
preface.append(untrusted_context_message(
|
||||||
@@ -470,6 +477,24 @@ class ChatProcessor:
|
|||||||
f"Content from {url}:\n\n{content}",
|
f"Content from {url}:\n\n{content}",
|
||||||
provenance_origin="external",
|
provenance_origin="external",
|
||||||
))
|
))
|
||||||
|
else:
|
||||||
|
# A failed automatic URL fetch is context too. Never pass
|
||||||
|
# exception text or response-controlled diagnostics back to
|
||||||
|
# the model: reduce the result to a small transport-owned
|
||||||
|
# status and explicitly state that the page was not read.
|
||||||
|
error = str(result.get("error") or "")
|
||||||
|
status = "the page was unavailable"
|
||||||
|
status_match = re.match(r"^HTTP\s+(\d{3})\b", error)
|
||||||
|
if status_match:
|
||||||
|
status = f"the server returned HTTP {status_match.group(1)}"
|
||||||
|
elif error.startswith("TooLarge:"):
|
||||||
|
status = "the response exceeded the fetch size limit"
|
||||||
|
elif error.startswith("Rate limit"):
|
||||||
|
status = "the request was rate limited"
|
||||||
|
preface.append(untrusted_context_message(
|
||||||
|
"web page fetch failure",
|
||||||
|
f"A linked page was not read: {status}.",
|
||||||
|
))
|
||||||
|
|
||||||
# Skills index — progressive disclosure. Only injected when the
|
# Skills index — progressive disclosure. Only injected when the
|
||||||
# model has the `manage_skills` tool available (agent_mode), and
|
# model has the `manage_skills` tool available (agent_mode), and
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import os
|
|||||||
|
|
||||||
from src.runtime_paths import get_app_root, get_default_data_dir
|
from src.runtime_paths import get_app_root, get_default_data_dir
|
||||||
|
|
||||||
APP_VERSION = "1.0.2"
|
APP_VERSION = "1.0.3"
|
||||||
|
|
||||||
# Base paths
|
# Base paths
|
||||||
BASE_DIR = os.path.join(get_app_root(), "")
|
BASE_DIR = os.path.join(get_app_root(), "")
|
||||||
|
|||||||
@@ -290,17 +290,22 @@ def detect_vendor(base_url: Any = "", endpoint_kind: Any = "") -> str:
|
|||||||
return kind_map[kind]
|
return kind_map[kind]
|
||||||
|
|
||||||
parsed = urlparse(compact_str(base_url))
|
parsed = urlparse(compact_str(base_url))
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower().rstrip(".")
|
||||||
port = parsed.port
|
port = parsed.port
|
||||||
if host.endswith("openrouter.ai"):
|
|
||||||
|
def host_matches(domain: str) -> bool:
|
||||||
|
domain = domain.lower().rstrip(".")
|
||||||
|
return host == domain or host.endswith(f".{domain}")
|
||||||
|
|
||||||
|
if host_matches("openrouter.ai"):
|
||||||
return VENDOR_OPENROUTER
|
return VENDOR_OPENROUTER
|
||||||
if host.endswith("openai.com"):
|
if host_matches("openai.com"):
|
||||||
return VENDOR_OPENAI
|
return VENDOR_OPENAI
|
||||||
if host.endswith("anthropic.com"):
|
if host_matches("anthropic.com"):
|
||||||
return VENDOR_ANTHROPIC
|
return VENDOR_ANTHROPIC
|
||||||
if host.endswith("googleapis.com"):
|
if host_matches("googleapis.com"):
|
||||||
return VENDOR_GOOGLE
|
return VENDOR_GOOGLE
|
||||||
if host.endswith("ollama.com") or port == 11434:
|
if host_matches("ollama.com") or port == 11434:
|
||||||
return VENDOR_OLLAMA
|
return VENDOR_OLLAMA
|
||||||
if port == 1234:
|
if port == 1234:
|
||||||
return VENDOR_LMSTUDIO
|
return VENDOR_LMSTUDIO
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""SSRF-guarded synchronous HTTP fetching primitives.
|
||||||
|
|
||||||
|
This module owns outbound URL classification, one-resolution-per-hop DNS
|
||||||
|
pinning, redirects, and response-body budgets. It deliberately has no search
|
||||||
|
or content-extraction dependencies so callers outside search can reuse the
|
||||||
|
same transport boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
from typing import Callable, Iterable, cast
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import httpcore
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.constants import WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_SOFT_MAX_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
_PRIVATE_NETWORKS = (
|
||||||
|
ipaddress.ip_network("0.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"),
|
||||||
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
ipaddress.ip_network("::1/128"),
|
||||||
|
ipaddress.ip_network("fc00::/7"),
|
||||||
|
ipaddress.ip_network("fe80::/10"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
|
||||||
|
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||||
|
addr = addr.ipv4_mapped
|
||||||
|
return (
|
||||||
|
addr.is_private
|
||||||
|
or addr.is_loopback
|
||||||
|
or addr.is_link_local
|
||||||
|
or addr.is_reserved
|
||||||
|
or addr.is_multicast
|
||||||
|
or addr.is_unspecified
|
||||||
|
or any(addr in net for net in _PRIVATE_NETWORKS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(hostname, None)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for info in infos:
|
||||||
|
try:
|
||||||
|
out.append(ipaddress.ip_address(info[4][0]))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _public_http_url(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
resolver: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
resolver = resolver or _resolve_hostname_ips
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
return False
|
||||||
|
host = (parsed.hostname or "").strip()
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
lower = host.lower()
|
||||||
|
if lower in ("localhost", "metadata", "metadata.google.internal"):
|
||||||
|
return False
|
||||||
|
if lower.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return not _is_private_address(ipaddress.ip_address(host))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
addrs = resolver(host)
|
||||||
|
return bool(addrs) and not any(_is_private_address(a) for a in addrs)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_public_ips(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
resolver: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||||
|
) -> list[ipaddress._BaseAddress]:
|
||||||
|
resolver = resolver or _resolve_hostname_ips
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||||
|
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||||
|
host = (parsed.hostname or "").strip().lower()
|
||||||
|
if host in ("localhost", "metadata", "metadata.google.internal"):
|
||||||
|
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
if _is_private_address(ip):
|
||||||
|
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
|
||||||
|
return [ip]
|
||||||
|
except httpx.RequestError:
|
||||||
|
raise
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
addrs = resolver(host)
|
||||||
|
if not addrs or any(_is_private_address(a) for a in addrs):
|
||||||
|
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||||
|
return addrs
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedBackend(httpcore.NetworkBackend):
|
||||||
|
"""Network backend that connects to a pre-resolved IP."""
|
||||||
|
|
||||||
|
def __init__(self, ip: ipaddress._BaseAddress):
|
||||||
|
self._ip = str(ip)
|
||||||
|
self._real = httpcore.SyncBackend()
|
||||||
|
|
||||||
|
def connect_tcp(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | None = None,
|
||||||
|
local_address: str | None = None,
|
||||||
|
socket_options=None,
|
||||||
|
):
|
||||||
|
return self._real.connect_tcp(
|
||||||
|
self._ip, port, timeout, local_address, socket_options
|
||||||
|
)
|
||||||
|
|
||||||
|
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||||
|
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||||
|
|
||||||
|
def sleep(self, seconds: float) -> None:
|
||||||
|
return self._real.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
_HTTPCORE_TO_HTTPX_EXC = {
|
||||||
|
httpcore.ConnectError: httpx.ConnectError,
|
||||||
|
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||||
|
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||||
|
httpcore.NetworkError: httpx.NetworkError,
|
||||||
|
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||||
|
httpcore.ProtocolError: httpx.ProtocolError,
|
||||||
|
httpcore.ProxyError: httpx.ProxyError,
|
||||||
|
httpcore.ReadError: httpx.ReadError,
|
||||||
|
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||||
|
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||||
|
httpcore.TimeoutException: httpx.TimeoutException,
|
||||||
|
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||||
|
httpcore.WriteError: httpx.WriteError,
|
||||||
|
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedTransport(httpx.BaseTransport):
|
||||||
|
"""Transport that pins every TCP connect to a pre-resolved IP."""
|
||||||
|
|
||||||
|
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
|
||||||
|
self._pool = httpcore.ConnectionPool(
|
||||||
|
ssl_context=ssl.create_default_context(),
|
||||||
|
http1=True,
|
||||||
|
http2=http2,
|
||||||
|
network_backend=_PinnedBackend(ip),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self._pool.__enter__()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
|
||||||
|
self._pool.__exit__(exc_type, exc_value, traceback)
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
httpcore_req = httpcore.Request(
|
||||||
|
method=request.method,
|
||||||
|
url=httpcore.URL(
|
||||||
|
scheme=request.url.raw_scheme,
|
||||||
|
host=request.url.raw_host,
|
||||||
|
port=request.url.port,
|
||||||
|
target=request.url.raw_path,
|
||||||
|
),
|
||||||
|
headers=request.headers.raw,
|
||||||
|
content=request.stream,
|
||||||
|
extensions=request.extensions,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
httpcore_resp = self._pool.handle_request(httpcore_req)
|
||||||
|
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
|
||||||
|
except Exception as exc:
|
||||||
|
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||||
|
if mapped is not None:
|
||||||
|
raise mapped(str(exc)) from exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
return httpx.Response(
|
||||||
|
status_code=httpcore_resp.status,
|
||||||
|
headers=httpcore_resp.headers,
|
||||||
|
content=content,
|
||||||
|
extensions=httpcore_resp.extensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._pool.close()
|
||||||
|
|
||||||
|
|
||||||
|
class BodyTooLargeError(Exception):
|
||||||
|
"""The server declared a body larger than the hard fetch ceiling."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, declared_bytes: int):
|
||||||
|
self.url = url
|
||||||
|
self.declared_bytes = declared_bytes
|
||||||
|
super().__init__(
|
||||||
|
f"response body is {declared_bytes:,} bytes, over the "
|
||||||
|
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _CappedFetch:
|
||||||
|
"""Result of a size-capped streaming GET."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"status_code",
|
||||||
|
"headers",
|
||||||
|
"content",
|
||||||
|
"truncated",
|
||||||
|
"declared_bytes",
|
||||||
|
"encoding",
|
||||||
|
"url",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
status_code,
|
||||||
|
headers,
|
||||||
|
content,
|
||||||
|
truncated,
|
||||||
|
declared_bytes,
|
||||||
|
encoding,
|
||||||
|
url,
|
||||||
|
):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.headers = headers
|
||||||
|
self.content = content
|
||||||
|
self.truncated = truncated
|
||||||
|
self.declared_bytes = declared_bytes
|
||||||
|
self.encoding = encoding
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return self.content.decode(self.encoding or "utf-8", errors="replace")
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.status_code >= 400:
|
||||||
|
request = httpx.Request("GET", self.url)
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
f"HTTP {self.status_code} for {self.url}",
|
||||||
|
request=request,
|
||||||
|
response=httpx.Response(self.status_code, request=request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_public_url(
|
||||||
|
url: str,
|
||||||
|
headers: dict,
|
||||||
|
timeout: int,
|
||||||
|
max_redirects: int = 5,
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
*,
|
||||||
|
resolve_public_ips: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||||
|
transport_factory: Callable[[ipaddress._BaseAddress], httpx.BaseTransport] | None = None,
|
||||||
|
) -> _CappedFetch:
|
||||||
|
"""Capped streaming GET with SSRF-guarded, DNS-pinned redirects."""
|
||||||
|
resolve_public_ips = resolve_public_ips or _resolve_public_ips
|
||||||
|
transport_factory = transport_factory or _PinnedTransport
|
||||||
|
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
||||||
|
current = url
|
||||||
|
for _ in range(max_redirects + 1):
|
||||||
|
ips = resolve_public_ips(current)
|
||||||
|
req_headers = dict(headers or {})
|
||||||
|
req_headers["Accept-Encoding"] = "identity"
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
headers=req_headers,
|
||||||
|
timeout=timeout,
|
||||||
|
follow_redirects=False,
|
||||||
|
transport=transport_factory(ips[0]),
|
||||||
|
) as client:
|
||||||
|
with client.stream("GET", current) as response:
|
||||||
|
if response.status_code in (301, 302, 303, 307, 308):
|
||||||
|
location = response.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
return _CappedFetch(
|
||||||
|
response.status_code,
|
||||||
|
response.headers,
|
||||||
|
b"",
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
response.encoding,
|
||||||
|
str(response.url),
|
||||||
|
)
|
||||||
|
current = urljoin(str(response.url), location)
|
||||||
|
continue
|
||||||
|
|
||||||
|
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
||||||
|
if enc and enc != "identity":
|
||||||
|
raise httpx.RequestError(
|
||||||
|
f"Refusing compressed response (Content-Encoding: {enc}) after "
|
||||||
|
"requesting identity: cannot bound decoded body size",
|
||||||
|
request=httpx.Request("GET", current),
|
||||||
|
)
|
||||||
|
|
||||||
|
declared = None
|
||||||
|
raw_len = response.headers.get("content-length")
|
||||||
|
if raw_len and raw_len.isdigit():
|
||||||
|
declared = int(raw_len)
|
||||||
|
|
||||||
|
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
||||||
|
raise BodyTooLargeError(current, declared)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
read = 0
|
||||||
|
truncated = False
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
read += len(chunk)
|
||||||
|
if read > cap:
|
||||||
|
keep = cap - (read - len(chunk))
|
||||||
|
if keep > 0:
|
||||||
|
chunks.append(chunk[:keep])
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
return _CappedFetch(
|
||||||
|
response.status_code,
|
||||||
|
response.headers,
|
||||||
|
b"".join(chunks),
|
||||||
|
truncated,
|
||||||
|
declared,
|
||||||
|
response.encoding,
|
||||||
|
str(response.url),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise httpx.RequestError(
|
||||||
|
"Too many redirects", request=httpx.Request("GET", current)
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Shared wire values and scope markers for tool approval continuations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
# Keep the existing wire values so the current route and no-build frontend do
|
||||||
|
# not need a second protocol migration. ``approve`` no longer means one action;
|
||||||
|
# it now selects chat-session scope.
|
||||||
|
TASK_APPROVAL_DECISION = "approve_task"
|
||||||
|
CHAT_SESSION_APPROVAL_DECISION = "approve"
|
||||||
|
DENY_APPROVAL_DECISION = "deny"
|
||||||
|
|
||||||
|
# Session.get_context_messages() adds this server-owned marker only when the
|
||||||
|
# session history contains a matching, resolved chat-session approval.
|
||||||
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalScope(str, Enum):
|
||||||
|
# Surfaces without a resumable chat (the skill tester, unattended audits)
|
||||||
|
# keep the original one-use meaning: the sealed action runs and the gate
|
||||||
|
# re-arms immediately for anything after it.
|
||||||
|
SINGLE_ACTION = "single_action"
|
||||||
|
TASK = "task"
|
||||||
|
CHAT_SESSION = "chat_session"
|
||||||
|
|
||||||
|
|
||||||
|
def scope_for_decision(decision: object) -> ToolApprovalScope | None:
|
||||||
|
normalized = str(decision or "").strip().lower()
|
||||||
|
if normalized == TASK_APPROVAL_DECISION:
|
||||||
|
return ToolApprovalScope.TASK
|
||||||
|
if normalized == CHAT_SESSION_APPROVAL_DECISION:
|
||||||
|
return ToolApprovalScope.CHAT_SESSION
|
||||||
|
return None
|
||||||
+136
-15
@@ -1,8 +1,9 @@
|
|||||||
"""Opaque, exact, one-use approvals for tainted model-requested actions.
|
"""Opaque exact-action approvals with explicit task and chat scopes.
|
||||||
|
|
||||||
The model may propose an action after untrusted context, but only the server
|
The server still seals and claims the first displayed action exactly once. The
|
||||||
stores and later executes the exact approved tool input. Browser-visible
|
selected scope then bypasses only the automatic post-external-context approval
|
||||||
fields are display copies, never authority.
|
gate for the rest of the resumed task or chat session. Browser-visible fields
|
||||||
|
are display copies, never authority.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,6 +17,13 @@ import time
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from src.tool_approval_scopes import (
|
||||||
|
CHAT_SESSION_APPROVAL_DECISION,
|
||||||
|
DENY_APPROVAL_DECISION,
|
||||||
|
TASK_APPROVAL_DECISION,
|
||||||
|
ToolApprovalScope,
|
||||||
|
scope_for_decision,
|
||||||
|
)
|
||||||
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +41,51 @@ def _normalized_workspace(workspace: Any) -> str:
|
|||||||
return os.path.realpath(os.path.expanduser(workspace))
|
return os.path.realpath(os.path.expanduser(workspace))
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_APPROVAL_SELECTED_TOOLS = 512
|
||||||
|
_MAX_APPROVAL_TOOL_NAME_CHARS = 512
|
||||||
|
_MAX_APPROVAL_CONTINUATION_QUERY_CHARS = 4000
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_selected_tools(
|
||||||
|
selected_tools: Any,
|
||||||
|
*,
|
||||||
|
required_tool: Any = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if isinstance(selected_tools, str):
|
||||||
|
selected_tools = (selected_tools,)
|
||||||
|
try:
|
||||||
|
values = selected_tools or ()
|
||||||
|
names = {
|
||||||
|
name.strip()
|
||||||
|
for name in values
|
||||||
|
if (
|
||||||
|
isinstance(name, str)
|
||||||
|
and name.strip()
|
||||||
|
and len(name.strip()) <= _MAX_APPROVAL_TOOL_NAME_CHARS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
required_name = str(required_tool or "").strip()
|
||||||
|
if required_name and len(required_name) <= _MAX_APPROVAL_TOOL_NAME_CHARS:
|
||||||
|
names.add(required_name)
|
||||||
|
ordered = sorted(names)
|
||||||
|
if len(ordered) <= _MAX_APPROVAL_SELECTED_TOOLS:
|
||||||
|
return tuple(ordered)
|
||||||
|
kept = ordered[:_MAX_APPROVAL_SELECTED_TOOLS]
|
||||||
|
if required_name and required_name in names and required_name not in kept:
|
||||||
|
kept[-1] = required_name
|
||||||
|
kept.sort()
|
||||||
|
return tuple(kept)
|
||||||
|
except TypeError:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_continuation_query(value: Any) -> str:
|
||||||
|
# The query is server-derived from the interrupted run and already lives in
|
||||||
|
# session history. Keep the pending copy bounded because approvals are held
|
||||||
|
# in memory until consumed or expired.
|
||||||
|
return str(value or "").strip()[:_MAX_APPROVAL_CONTINUATION_QUERY_CHARS]
|
||||||
|
|
||||||
|
|
||||||
def _canonical_digest(payload: dict[str, Any]) -> str:
|
def _canonical_digest(payload: dict[str, Any]) -> str:
|
||||||
encoded = json.dumps(
|
encoded = json.dumps(
|
||||||
payload,
|
payload,
|
||||||
@@ -60,6 +113,8 @@ def _binding_payload(
|
|||||||
document_version: Any,
|
document_version: Any,
|
||||||
document_digest: Any,
|
document_digest: Any,
|
||||||
external_untrusted_context_seen: bool,
|
external_untrusted_context_seen: bool,
|
||||||
|
selected_tools: Any,
|
||||||
|
continuation_query: Any,
|
||||||
effects: tuple[str, ...],
|
effects: tuple[str, ...],
|
||||||
result_integrity: str,
|
result_integrity: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -76,6 +131,10 @@ def _binding_payload(
|
|||||||
),
|
),
|
||||||
"document_digest": str(document_digest or "").strip().lower(),
|
"document_digest": str(document_digest or "").strip().lower(),
|
||||||
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
|
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
|
||||||
|
"selected_tools": list(
|
||||||
|
_normalized_selected_tools(selected_tools, required_tool=tool_name)
|
||||||
|
),
|
||||||
|
"continuation_query": _normalized_continuation_query(continuation_query),
|
||||||
"effects": list(effects),
|
"effects": list(effects),
|
||||||
"result_integrity": str(result_integrity),
|
"result_integrity": str(result_integrity),
|
||||||
}
|
}
|
||||||
@@ -99,26 +158,47 @@ class PendingToolApproval:
|
|||||||
digest: str
|
digest: str
|
||||||
created_at: float
|
created_at: float
|
||||||
expires_at: float
|
expires_at: float
|
||||||
|
# Server-only continuation state. Both fields are digest-bound and never
|
||||||
|
# exposed in the browser payload.
|
||||||
|
selected_tools: tuple[str, ...] = ()
|
||||||
|
continuation_query: str = ""
|
||||||
|
|
||||||
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"kind": "tool_approval",
|
"kind": "tool_approval",
|
||||||
"approval_id": self.approval_id,
|
"approval_id": self.approval_id,
|
||||||
"question": "Allow this exact action once?",
|
# The browser already owns this chat id. Persisting it with the
|
||||||
|
# resolved card lets history-derived session grants remain bound to
|
||||||
|
# this exact chat and prevents inheritance by a forked session.
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"question": "Allow this task to continue?",
|
||||||
"description": reason or (
|
"description": reason or (
|
||||||
"Untrusted context influenced this run, so this action needs "
|
"Untrusted context influenced this run, so continuing with "
|
||||||
"your explicit approval."
|
"otherwise-gated actions needs your explicit approval."
|
||||||
),
|
),
|
||||||
"options": [
|
"options": [
|
||||||
{
|
{
|
||||||
"label": "Allow once",
|
"label": "Allow for this task",
|
||||||
"value": "approve",
|
"value": TASK_APPROVAL_DECISION,
|
||||||
"description": "Execute only the sealed action shown here.",
|
"description": (
|
||||||
|
"Execute the sealed action and allow every otherwise-gated "
|
||||||
|
"action needed to finish this request. Current tool, account, "
|
||||||
|
"workspace, and sandbox restrictions still apply."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Allow for this chat session",
|
||||||
|
"value": CHAT_SESSION_APPROVAL_DECISION,
|
||||||
|
"description": (
|
||||||
|
"Execute the sealed action and stop asking at this gate for "
|
||||||
|
"later requests in this chat. Current tool, account, workspace, "
|
||||||
|
"and sandbox restrictions still apply."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Deny",
|
"label": "Deny",
|
||||||
"value": "deny",
|
"value": DENY_APPROVAL_DECISION,
|
||||||
"description": "Do not execute it.",
|
"description": "Do not execute the proposed action.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"action": {
|
"action": {
|
||||||
@@ -137,12 +217,23 @@ class PendingToolApproval:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExactToolApproval:
|
class ExactToolApproval:
|
||||||
"""A consumed grant that the dispatcher can claim exactly once."""
|
"""A consumed exact first action plus an explicit continuation scope."""
|
||||||
|
|
||||||
pending: PendingToolApproval
|
pending: PendingToolApproval
|
||||||
|
scope: ToolApprovalScope = ToolApprovalScope.TASK
|
||||||
|
# The seam consumed by agent_loop. Both chat-card allow choices cover the
|
||||||
|
# complete resumed task, because one-action scope there immediately
|
||||||
|
# re-entered the same gate on the next round. Callers with no resumable
|
||||||
|
# chat still get SINGLE_ACTION, which leaves the gate armed behind the
|
||||||
|
# sealed action.
|
||||||
|
allow_remaining_actions: bool = True
|
||||||
_claimed: bool = field(default=False, init=False, repr=False)
|
_claimed: bool = field(default=False, init=False, repr=False)
|
||||||
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def grants_chat_session(self) -> bool:
|
||||||
|
return self.scope is ToolApprovalScope.CHAT_SESSION
|
||||||
|
|
||||||
def _matches_unlocked(
|
def _matches_unlocked(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -175,6 +266,8 @@ class ExactToolApproval:
|
|||||||
external_untrusted_context_seen=(
|
external_untrusted_context_seen=(
|
||||||
self.pending.external_untrusted_context_seen
|
self.pending.external_untrusted_context_seen
|
||||||
),
|
),
|
||||||
|
selected_tools=self.pending.selected_tools,
|
||||||
|
continuation_query=self.pending.continuation_query,
|
||||||
effects=effects,
|
effects=effects,
|
||||||
result_integrity=result_integrity,
|
result_integrity=result_integrity,
|
||||||
)
|
)
|
||||||
@@ -255,6 +348,8 @@ class ToolApprovalStore:
|
|||||||
document_id: Any = None,
|
document_id: Any = None,
|
||||||
document_version: Any = None,
|
document_version: Any = None,
|
||||||
document_digest: Any = None,
|
document_digest: Any = None,
|
||||||
|
selected_tools: Any = None,
|
||||||
|
continuation_query: Any = None,
|
||||||
external_untrusted_context_seen: bool,
|
external_untrusted_context_seen: bool,
|
||||||
capabilities: ToolCapabilities,
|
capabilities: ToolCapabilities,
|
||||||
) -> PendingToolApproval:
|
) -> PendingToolApproval:
|
||||||
@@ -272,6 +367,8 @@ class ToolApprovalStore:
|
|||||||
document_version=document_version,
|
document_version=document_version,
|
||||||
document_digest=document_digest,
|
document_digest=document_digest,
|
||||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
|
selected_tools=selected_tools,
|
||||||
|
continuation_query=continuation_query,
|
||||||
effects=effects,
|
effects=effects,
|
||||||
result_integrity=result_integrity,
|
result_integrity=result_integrity,
|
||||||
)
|
)
|
||||||
@@ -294,6 +391,8 @@ class ToolApprovalStore:
|
|||||||
digest=_canonical_digest(payload),
|
digest=_canonical_digest(payload),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
expires_at=now + self._ttl_seconds,
|
expires_at=now + self._ttl_seconds,
|
||||||
|
selected_tools=tuple(payload["selected_tools"]),
|
||||||
|
continuation_query=payload["continuation_query"],
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._purge_expired_locked(now)
|
self._purge_expired_locked(now)
|
||||||
@@ -331,7 +430,17 @@ class ToolApprovalStore:
|
|||||||
decision: Any,
|
decision: Any,
|
||||||
owner: Any,
|
owner: Any,
|
||||||
session_id: Any,
|
session_id: Any,
|
||||||
|
allow_continuation: bool = True,
|
||||||
) -> ExactToolApproval | None:
|
) -> ExactToolApproval | None:
|
||||||
|
"""Consume a pending approval.
|
||||||
|
|
||||||
|
``allow_continuation`` is the caller's assertion that it owns a
|
||||||
|
resumable conversation the granted scope can apply to. Callers without
|
||||||
|
one (the skill tester, unattended audits) pass ``False`` and get the
|
||||||
|
original one-use grant, so a button labelled "Allow once" cannot widen
|
||||||
|
into a run-long bypass just because the chat card reuses the same wire
|
||||||
|
value.
|
||||||
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._purge_expired_locked(now)
|
self._purge_expired_locked(now)
|
||||||
@@ -348,9 +457,21 @@ class ToolApprovalStore:
|
|||||||
# another owner's pending action.
|
# another owner's pending action.
|
||||||
return None
|
return None
|
||||||
self._pending.pop(approval_key, None)
|
self._pending.pop(approval_key, None)
|
||||||
if str(decision or "").strip().lower() != "approve":
|
normalized_decision = str(decision or "").strip().lower()
|
||||||
|
scope = scope_for_decision(normalized_decision)
|
||||||
|
if scope is None:
|
||||||
return None
|
return None
|
||||||
return ExactToolApproval(pending)
|
if not allow_continuation:
|
||||||
|
return ExactToolApproval(
|
||||||
|
pending,
|
||||||
|
scope=ToolApprovalScope.SINGLE_ACTION,
|
||||||
|
allow_remaining_actions=False,
|
||||||
|
)
|
||||||
|
return ExactToolApproval(
|
||||||
|
pending,
|
||||||
|
scope=scope,
|
||||||
|
allow_remaining_actions=True,
|
||||||
|
)
|
||||||
|
|
||||||
def peek(self, approval_id: Any) -> PendingToolApproval | None:
|
def peek(self, approval_id: Any) -> PendingToolApproval | None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from enum import Enum
|
|||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any, Iterable, Mapping
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||||
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
|
|
||||||
@@ -618,13 +619,30 @@ class ToolRunSecurityContext:
|
|||||||
external_untrusted_context_seen: bool = False
|
external_untrusted_context_seen: bool = False
|
||||||
external_sources: list[str] = field(default_factory=list)
|
external_sources: list[str] = field(default_factory=list)
|
||||||
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
# Task-scope approval sets this for the resumed in-memory run. Chat-scope
|
||||||
|
# approval is projected from the server-owned session history marker below.
|
||||||
|
# The bypass affects only this automatic gate; current tool policy, ownership,
|
||||||
|
# workspace confinement, and execution/sandbox restrictions still apply.
|
||||||
|
approval_gate_bypassed: bool = False
|
||||||
|
|
||||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||||
"""Promote any server-labelled untrusted prompt context into the gate."""
|
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||||
if messages_contain_external_untrusted_context(messages):
|
message_list = list(messages or ())
|
||||||
|
if any(
|
||||||
|
isinstance(message, dict)
|
||||||
|
and isinstance(message.get("metadata"), dict)
|
||||||
|
and message["metadata"].get(
|
||||||
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||||
|
) is True
|
||||||
|
for message in message_list
|
||||||
|
):
|
||||||
|
self.approval_gate_bypassed = True
|
||||||
|
if messages_contain_external_untrusted_context(message_list):
|
||||||
self.external_untrusted_context_seen = True
|
self.external_untrusted_context_seen = True
|
||||||
|
|
||||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||||
|
if self.approval_gate_bypassed:
|
||||||
|
return ToolGateDecision(True)
|
||||||
if not self.external_untrusted_context_seen:
|
if not self.external_untrusted_context_seen:
|
||||||
return ToolGateDecision(True)
|
return ToolGateDecision(True)
|
||||||
capabilities = capabilities_for_action(tool_name, content)
|
capabilities = capabilities_for_action(tool_name, content)
|
||||||
|
|||||||
+31
-20
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone, tzinfo
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
@@ -65,19 +65,31 @@ def format_utc_offset(offset_min: Optional[int]) -> str:
|
|||||||
return f"{sign}{hours:02d}:{minutes:02d}"
|
return f"{sign}{hours:02d}:{minutes:02d}"
|
||||||
|
|
||||||
|
|
||||||
def user_timezone() -> timezone:
|
def _zoneinfo_from_name():
|
||||||
"""Return the best known user timezone as a fixed-offset tzinfo."""
|
"""Return ZoneInfo for the request's IANA name, or None if missing/invalid."""
|
||||||
|
name = get_user_tz_name()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
return ZoneInfo(name)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def user_timezone() -> tzinfo:
|
||||||
|
"""Return the best known user timezone.
|
||||||
|
|
||||||
|
A valid IANA name wins over x-tz-offset. The offset is a fixed number and
|
||||||
|
can disagree with the name (wrong sign, stale client); the name carries DST.
|
||||||
|
"""
|
||||||
|
zone = _zoneinfo_from_name()
|
||||||
|
if zone is not None:
|
||||||
|
return zone
|
||||||
offset = get_user_tz_offset()
|
offset = get_user_tz_offset()
|
||||||
if offset is None:
|
if offset is not None:
|
||||||
name = get_user_tz_name()
|
return timezone(timedelta(minutes=offset))
|
||||||
if name:
|
return datetime.now().astimezone().tzinfo or timezone.utc
|
||||||
try:
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
return ZoneInfo(name)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return datetime.now().astimezone().tzinfo or timezone.utc
|
|
||||||
return timezone(timedelta(minutes=offset))
|
|
||||||
|
|
||||||
|
|
||||||
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
|
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
|
||||||
@@ -100,14 +112,13 @@ def _clock_label(dt: datetime) -> str:
|
|||||||
|
|
||||||
def timezone_label(dt: Optional[datetime] = None) -> str:
|
def timezone_label(dt: Optional[datetime] = None) -> str:
|
||||||
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
|
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
|
||||||
offset = get_user_tz_offset()
|
if dt is None:
|
||||||
if offset is None:
|
dt = now_user_local()
|
||||||
if dt is None:
|
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
|
||||||
dt = datetime.now().astimezone()
|
|
||||||
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
|
|
||||||
offset_label = f"UTC{format_utc_offset(offset)}"
|
offset_label = f"UTC{format_utc_offset(offset)}"
|
||||||
name = get_user_tz_name()
|
if _zoneinfo_from_name() is not None:
|
||||||
return f"{name}, {offset_label}" if name else offset_label
|
return f"{get_user_tz_name()}, {offset_label}"
|
||||||
|
return offset_label
|
||||||
|
|
||||||
|
|
||||||
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
|
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
|
||||||
|
|||||||
+3
-3
@@ -10,8 +10,8 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
|
|||||||
import ragModule from './js/rag.js';
|
import ragModule from './js/rag.js';
|
||||||
import presetsModule from './js/presets.js';
|
import presetsModule from './js/presets.js';
|
||||||
import searchModule from './js/search.js';
|
import searchModule from './js/search.js';
|
||||||
import chatModule from './js/chat.js?v=20260815toolapproval4';
|
import chatModule from './js/chat.js?v=20260819approvalcontrol1';
|
||||||
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
import compareModule from './js/compare/index.js?v=20260819approvalcontrol1';
|
||||||
import documentModule from './js/document.js?v=20260815approvalsave1';
|
import documentModule from './js/document.js?v=20260815approvalsave1';
|
||||||
import searchChatModule from './js/search-chat.js';
|
import searchChatModule from './js/search-chat.js';
|
||||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
settleSessionHydration
|
settleSessionHydration
|
||||||
} from './js/startupShell.js';
|
} from './js/startupShell.js';
|
||||||
import markdownModule from './js/markdown.js';
|
import markdownModule from './js/markdown.js';
|
||||||
import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
|
import chatRenderer from './js/chatRenderer.js?v=20260819approvalcontrol1';
|
||||||
import sessionModule from './js/sessions.js';
|
import sessionModule from './js/sessions.js';
|
||||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||||
|
|||||||
+8
-20
@@ -231,23 +231,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- KaTeX CSS is loaded with media="print" so it doesn't block render,
|
<!-- KaTeX and Mermaid are vendored in /static/lib and pulled in by
|
||||||
then flipped to "all" via JS after load. Mermaid init runs once the
|
static/js/markdown.js the first time a page actually renders math or a
|
||||||
library finishes loading. Both hooks are wired via addEventListener
|
```mermaid fence. They used to load here from cdn.jsdelivr.net on every
|
||||||
below (inline onload= attrs are blocked by CSP script-src-attr). -->
|
page load, which cost ~985 KB on the wire, broke offline installs, and
|
||||||
<link id="katex-css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" media="print">
|
announced every session to a third party. -->
|
||||||
<script async src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js"></script>
|
|
||||||
<script id="mermaid-script" async src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
|
||||||
<script nonce="{{CSP_NONCE}}">
|
|
||||||
(function(){
|
|
||||||
var k = document.getElementById('katex-css');
|
|
||||||
if (k) k.addEventListener('load', function(){ k.media = 'all'; }, { once: true });
|
|
||||||
var m = document.getElementById('mermaid-script');
|
|
||||||
if (m) m.addEventListener('load', function(){
|
|
||||||
if (window.odysseusInitMermaid) window.odysseusInitMermaid();
|
|
||||||
}, { once: true });
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
|
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
|
||||||
the app font and the weight the sidebar and header text render at. They
|
the app font and the weight the sidebar and header text render at. They
|
||||||
are declared in style.css, so without a hint they are only discovered
|
are declared in style.css, so without a hint they are only discovered
|
||||||
@@ -2584,10 +2572,10 @@
|
|||||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||||
<script type="module" src="/static/js/document.js?v=20260815approvalsave1"></script>
|
<script type="module" src="/static/js/document.js?v=20260815approvalsave1"></script>
|
||||||
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
|
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
|
||||||
<script type="module" src="/static/js/chatRenderer.js?v=20260815toolapproval4"></script>
|
<script type="module" src="/static/js/chatRenderer.js?v=20260819approvalcontrol1"></script>
|
||||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||||
<script type="module" src="/static/js/chatStream.js?v=20260815approvalsave1"></script>
|
<script type="module" src="/static/js/chatStream.js?v=20260819approvalcontrol1"></script>
|
||||||
<script type="module" src="/static/js/chat.js?v=20260815toolapproval4"></script>
|
<script type="module" src="/static/js/chat.js?v=20260819approvalcontrol1"></script>
|
||||||
<script type="module" src="/static/js/cookbook.js"></script>
|
<script type="module" src="/static/js/cookbook.js"></script>
|
||||||
<script src="/static/js/cookbookSchedule.js"></script>
|
<script src="/static/js/cookbookSchedule.js"></script>
|
||||||
<script type="module" src="/static/js/search-chat.js"></script>
|
<script type="module" src="/static/js/search-chat.js"></script>
|
||||||
|
|||||||
+19
-16
@@ -8,8 +8,8 @@
|
|||||||
import Storage from './storage.js';
|
import Storage from './storage.js';
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import sessionModule from './sessions.js';
|
import sessionModule from './sessions.js';
|
||||||
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
|
import chatRenderer from './chatRenderer.js?v=20260819approvalcontrol1';
|
||||||
import chatStream from './chatStream.js?v=20260815approvalsave1';
|
import chatStream from './chatStream.js?v=20260819approvalcontrol1';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
@@ -62,20 +62,18 @@ import { loadPanel } from './panels.js';
|
|||||||
let _contextHeaderBound = false;
|
let _contextHeaderBound = false;
|
||||||
let _pendingToolApproval = null;
|
let _pendingToolApproval = null;
|
||||||
|
|
||||||
function _submitToolApprovalWhenIdle(approvalId, label) {
|
function _submitToolApprovalWhenIdle(approvalId) {
|
||||||
if (
|
if (
|
||||||
!_pendingToolApproval
|
!_pendingToolApproval
|
||||||
|| _pendingToolApproval.approval_id !== approvalId
|
|| _pendingToolApproval.approval_id !== approvalId
|
||||||
) return;
|
) return;
|
||||||
if (isStreaming || _sendInFlight) {
|
if (isStreaming || _sendInFlight) {
|
||||||
setTimeout(() => _submitToolApprovalWhenIdle(approvalId, label), 120);
|
setTimeout(() => _submitToolApprovalWhenIdle(approvalId), 120);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const input = document.getElementById('message');
|
const input = document.getElementById('message');
|
||||||
if (input) {
|
if (input) {
|
||||||
_pendingToolApproval.draft = input.value || '';
|
_pendingToolApproval.draft = input.value || '';
|
||||||
input.value = label;
|
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}
|
}
|
||||||
const sendButton = document.querySelector('.send-btn');
|
const sendButton = document.querySelector('.send-btn');
|
||||||
if (sendButton) sendButton.click();
|
if (sendButton) sendButton.click();
|
||||||
@@ -84,16 +82,13 @@ import { loadPanel } from './panels.js';
|
|||||||
document.addEventListener('odysseus:tool-approval', (event) => {
|
document.addEventListener('odysseus:tool-approval', (event) => {
|
||||||
const detail = event && event.detail ? event.detail : {};
|
const detail = event && event.detail ? event.detail : {};
|
||||||
const decision = String(detail.decision || '').toLowerCase();
|
const decision = String(detail.decision || '').toLowerCase();
|
||||||
if (!detail.approval_id || !['approve', 'deny'].includes(decision)) return;
|
if (!detail.approval_id || !['approve', 'approve_task', 'deny'].includes(decision)) return;
|
||||||
_pendingToolApproval = {
|
_pendingToolApproval = {
|
||||||
approval_id: String(detail.approval_id),
|
approval_id: String(detail.approval_id),
|
||||||
decision,
|
decision,
|
||||||
document_id: String(detail.document_id || ''),
|
document_id: String(detail.document_id || ''),
|
||||||
};
|
};
|
||||||
_submitToolApprovalWhenIdle(
|
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
|
||||||
_pendingToolApproval.approval_id,
|
|
||||||
detail.label || (decision === 'approve' ? 'Allow once' : 'Deny'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function _fmtContextNumber(n) {
|
function _fmtContextNumber(n) {
|
||||||
@@ -1309,10 +1304,10 @@ import { loadPanel } from './panels.js';
|
|||||||
}
|
}
|
||||||
|
|
||||||
const el = uiModule.el;
|
const el = uiModule.el;
|
||||||
const msg = el('message').value;
|
const msg = approvalForSend ? '' : el('message').value;
|
||||||
// Allow empty text when a regen carries over the original message's
|
// Allow empty text when a regen carries over the original message's
|
||||||
// attachment ids — a photo-only message still has something to send.
|
// attachment ids — a photo-only message still has something to send.
|
||||||
if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
if (!msg.trim() && !approvalForSend && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
||||||
|
|
||||||
// --- Slash commands: execute directly without AI (no session needed) ---
|
// --- Slash commands: execute directly without AI (no session needed) ---
|
||||||
if (!approvalForSend && isCommand(msg.trim())) {
|
if (!approvalForSend && isCommand(msg.trim())) {
|
||||||
@@ -1590,7 +1585,7 @@ import { loadPanel } from './panels.js';
|
|||||||
|
|
||||||
const userDisplay = _displayOverride || msg;
|
const userDisplay = _displayOverride || msg;
|
||||||
_displayOverride = null;
|
_displayOverride = null;
|
||||||
const skipBubble = _hideUserBubble;
|
const skipBubble = _hideUserBubble || !!approvalForSend;
|
||||||
_hideUserBubble = false;
|
_hideUserBubble = false;
|
||||||
// Auto-recovery counter: carries across a turn's auto-continues, but resets
|
// Auto-recovery counter: carries across a turn's auto-continues, but resets
|
||||||
// when the user genuinely sends a new message (so each task gets a fresh cap).
|
// when the user genuinely sends a new message (so each task gets a fresh cap).
|
||||||
@@ -1833,7 +1828,7 @@ import { loadPanel } from './panels.js';
|
|||||||
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
|
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
|
||||||
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('message', _finalMsgWithInject);
|
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
|
||||||
fd.append('session', streamSessionId);
|
fd.append('session', streamSessionId);
|
||||||
if (approvalForSend) {
|
if (approvalForSend) {
|
||||||
fd.append('tool_approval_id', approvalForSend.approval_id);
|
fd.append('tool_approval_id', approvalForSend.approval_id);
|
||||||
@@ -2873,7 +2868,7 @@ import { loadPanel } from './panels.js';
|
|||||||
if (spinner && spinner.element) spinner.destroy();
|
if (spinner && spinner.element) spinner.destroy();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
if (json.delta || json.type === 'agent_prep' || json.type === 'tool_approval_resolved' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||||
clearResponseTimeout();
|
clearResponseTimeout();
|
||||||
clearProcessingProbe();
|
clearProcessingProbe();
|
||||||
clearFirstTokenWaitTimers();
|
clearFirstTokenWaitTimers();
|
||||||
@@ -2890,6 +2885,14 @@ import { loadPanel } from './panels.js';
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (json.type === 'tool_approval_resolved') {
|
||||||
|
_cancelThinkingTimer();
|
||||||
|
_removeThinkingSpinner();
|
||||||
|
if (spinner && spinner.element) spinner.destroy();
|
||||||
|
if (!_isBg && roundHolder && roundHolder !== holder) roundHolder.remove();
|
||||||
|
if (!_isBg && holder) holder.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (json.delta) {
|
if (json.delta) {
|
||||||
_cancelThinkingTimer();
|
_cancelThinkingTimer();
|
||||||
_removeThinkingSpinner();
|
_removeThinkingSpinner();
|
||||||
|
|||||||
+75
-15
@@ -2327,6 +2327,42 @@ export function removeAskUserCards(root) {
|
|||||||
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While a choice card is visible, let plain 1–3 activate the corresponding
|
||||||
|
// rendered option. Reuse the option's click path so the question keeps its
|
||||||
|
// existing submission semantics. Tool approval cards are excluded: that card
|
||||||
|
// exists to make consent deliberate after untrusted context influenced the
|
||||||
|
// run, and its first option is the widest grant, so a stray digit must not
|
||||||
|
// answer it.
|
||||||
|
function _handleAskUserShortcut(event) {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented
|
||||||
|
|| event.repeat
|
||||||
|
|| event.isComposing
|
||||||
|
|| event.ctrlKey
|
||||||
|
|| event.altKey
|
||||||
|
|| event.metaKey
|
||||||
|
|| event.shiftKey
|
||||||
|
) return;
|
||||||
|
if (!/^[1-3]$/.test(event.key)) return;
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||||
|
|
||||||
|
const focusedCard = document.activeElement?.closest?.('.ask-user-card') || null;
|
||||||
|
const mainCard = document.querySelector('#chat-history .ask-user-card');
|
||||||
|
const compareCards = document.querySelectorAll('.compare-pane .ask-user-card');
|
||||||
|
const card = focusedCard || mainCard || (compareCards.length === 1 ? compareCards[0] : null);
|
||||||
|
if (!card) return;
|
||||||
|
if (card.dataset.askUserKind === 'tool_approval') return;
|
||||||
|
const option = card.querySelectorAll('.ask-user-option')[Number(event.key) - 1];
|
||||||
|
if (!option || option.disabled) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
option.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', _handleAskUserShortcut);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an ask_user payload as a durable choice card.
|
* Render an ask_user payload as a durable choice card.
|
||||||
*
|
*
|
||||||
@@ -2336,11 +2372,15 @@ export function removeAskUserCards(root) {
|
|||||||
*/
|
*/
|
||||||
export function renderAskUserCard(payload, options) {
|
export function renderAskUserCard(payload, options) {
|
||||||
const aq = payload || {};
|
const aq = payload || {};
|
||||||
|
if (aq.resolved) return null;
|
||||||
const opts = Array.isArray(aq.options) ? aq.options : [];
|
const opts = Array.isArray(aq.options) ? aq.options : [];
|
||||||
const chatBox = document.getElementById('chat-history');
|
const renderOptions = options || {};
|
||||||
|
const chatBox = renderOptions.root || document.getElementById('chat-history');
|
||||||
|
const onSubmit = typeof renderOptions.onSubmit === 'function'
|
||||||
|
? renderOptions.onSubmit
|
||||||
|
: null;
|
||||||
if (!chatBox || !aq.question || opts.length < 2) return null;
|
if (!chatBox || !aq.question || opts.length < 2) return null;
|
||||||
|
|
||||||
const renderOptions = options || {};
|
|
||||||
removeAskUserCards(chatBox);
|
removeAskUserCards(chatBox);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -2349,6 +2389,7 @@ export function renderAskUserCard(payload, options) {
|
|||||||
card.tabIndex = -1;
|
card.tabIndex = -1;
|
||||||
const multi = !!aq.multi;
|
const multi = !!aq.multi;
|
||||||
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
|
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
|
||||||
|
card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';
|
||||||
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||||
|
|
||||||
const head = document.createElement('div');
|
const head = document.createElement('div');
|
||||||
@@ -2357,7 +2398,6 @@ export function renderAskUserCard(payload, options) {
|
|||||||
closeBtn.type = 'button';
|
closeBtn.type = 'button';
|
||||||
closeBtn.className = 'modal-close ask-user-close';
|
closeBtn.className = 'modal-close ask-user-close';
|
||||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||||
closeBtn.textContent = '×';
|
|
||||||
closeBtn.addEventListener('click', () => {
|
closeBtn.addEventListener('click', () => {
|
||||||
card.remove();
|
card.remove();
|
||||||
const input = uiModule.el('message');
|
const input = uiModule.el('message');
|
||||||
@@ -2400,6 +2440,17 @@ export function renderAskUserCard(payload, options) {
|
|||||||
|
|
||||||
const send = (text) => {
|
const send = (text) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
if (onSubmit) {
|
||||||
|
const accepted = onSubmit({
|
||||||
|
kind: 'answer',
|
||||||
|
text,
|
||||||
|
label: text,
|
||||||
|
payload: aq,
|
||||||
|
card,
|
||||||
|
});
|
||||||
|
if (accepted !== false) card.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
card.remove();
|
card.remove();
|
||||||
const input = uiModule.el('message');
|
const input = uiModule.el('message');
|
||||||
if (input) input.value = text;
|
if (input) input.value = text;
|
||||||
@@ -2433,17 +2484,26 @@ export function renderAskUserCard(payload, options) {
|
|||||||
row.type = 'button';
|
row.type = 'button';
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
if (isToolApproval) {
|
if (isToolApproval) {
|
||||||
card.remove();
|
const detail = {
|
||||||
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', {
|
approval_id: aq.approval_id,
|
||||||
detail: {
|
decision: String((opt && opt.value) || '').toLowerCase(),
|
||||||
approval_id: aq.approval_id,
|
label,
|
||||||
decision: String((opt && opt.value) || '').toLowerCase(),
|
document_id: aq.action && aq.action.document_id
|
||||||
label,
|
? String(aq.action.document_id)
|
||||||
document_id: aq.action && aq.action.document_id
|
: '',
|
||||||
? String(aq.action.document_id)
|
};
|
||||||
: '',
|
if (onSubmit) {
|
||||||
},
|
const accepted = onSubmit({
|
||||||
}));
|
kind: 'tool_approval',
|
||||||
|
...detail,
|
||||||
|
payload: aq,
|
||||||
|
card,
|
||||||
|
});
|
||||||
|
if (accepted !== false) card.remove();
|
||||||
|
} else {
|
||||||
|
card.remove();
|
||||||
|
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
send(label);
|
send(label);
|
||||||
}
|
}
|
||||||
@@ -2628,7 +2688,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
box.appendChild(threadWrap);
|
box.appendChild(threadWrap);
|
||||||
}
|
}
|
||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
||||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||||
let outHtml = '';
|
let outHtml = '';
|
||||||
if (ev.output && ev.output.trim()) {
|
if (ev.output && ev.output.trim()) {
|
||||||
|
|||||||
@@ -9,6 +9,35 @@ import markdownModule from './markdown.js';
|
|||||||
import sessionModule from './sessions.js';
|
import sessionModule from './sessions.js';
|
||||||
import documentModule from './document.js?v=20260815approvalsave1';
|
import documentModule from './document.js?v=20260815approvalsave1';
|
||||||
|
|
||||||
|
// Tool approvals are control-plane submits for the current chat. chat.js
|
||||||
|
// deliberately leaves the composer untouched, then programmatically clicks the
|
||||||
|
// shared send button after it records the sealed approval id/decision. That
|
||||||
|
// button is polymorphic: with an empty composer it can mean New chat or Record
|
||||||
|
// voice instead of Send. Intercept only the programmatic approval click and
|
||||||
|
// route it through the form submit path, which already reaches chat.js directly.
|
||||||
|
document.addEventListener('odysseus:tool-approval', () => {
|
||||||
|
const sendButton = document.querySelector('.send-btn');
|
||||||
|
const chatForm = document.getElementById('chat-form');
|
||||||
|
if (!sendButton || !chatForm) return;
|
||||||
|
|
||||||
|
const interceptApprovalClick = (event) => {
|
||||||
|
// A real user click must retain the normal send/new-chat/STT behavior.
|
||||||
|
if (event.isTrusted) return;
|
||||||
|
sendButton.removeEventListener('click', interceptApprovalClick, true);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (chatForm.requestSubmit) chatForm.requestSubmit();
|
||||||
|
else chatForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
sendButton.addEventListener('click', interceptApprovalClick, true);
|
||||||
|
// Fail-safe cleanup if the approval continuation never reaches its deferred
|
||||||
|
// synthetic click (for example because the surrounding view is torn down).
|
||||||
|
setTimeout(() => {
|
||||||
|
sendButton.removeEventListener('click', interceptApprovalClick, true);
|
||||||
|
}, 60000);
|
||||||
|
}, true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
||||||
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
|
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
|
|||||||
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
||||||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
|
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
|
||||||
import {
|
import {
|
||||||
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
||||||
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
||||||
@@ -1006,11 +1006,16 @@ async function _executeCompare(message) {
|
|||||||
console.error('Compare error:', err);
|
console.error('Compare error:', err);
|
||||||
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
|
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
state._streaming = false;
|
// A pane may have started its own ask_user/approval continuation while the
|
||||||
_setSendBtn('send');
|
// original all-pane Promise was settling. Keep Compare busy until every
|
||||||
// Re-enable header buttons
|
// pane-owned controller is gone instead of exposing a second broadcast send.
|
||||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
|
const compareStillStreaming = state._abortControllers.some(Boolean);
|
||||||
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
|
state._streaming = compareStillStreaming;
|
||||||
|
_setSendBtn(compareStillStreaming ? 'stop' : 'send');
|
||||||
|
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
|
||||||
|
button.disabled = compareStillStreaming;
|
||||||
|
button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
|
||||||
|
button.style.pointerEvents = compareStillStreaming ? 'none' : '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1514,7 +1519,7 @@ async function showShufflePoolEditor() {
|
|||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerCompareActions({ stopAll, resetCompare });
|
registerCompareActions({ stopAll, resetCompare });
|
||||||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
|
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
|
||||||
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
|
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+189
-6
@@ -1,7 +1,7 @@
|
|||||||
// compare/stream.js — SSE streaming to panes
|
// compare/stream.js — SSE streaming to panes
|
||||||
import state from './state.js';
|
import state from './state.js';
|
||||||
import { addFinishBadge } from './vote.js';
|
import { addFinishBadge } from './vote.js';
|
||||||
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
|
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260819approvalcontrol1';
|
||||||
import markdownModule from '../markdown.js';
|
import markdownModule from '../markdown.js';
|
||||||
import spinnerModule from '../spinner.js';
|
import spinnerModule from '../spinner.js';
|
||||||
import uiModule from '../ui.js';
|
import uiModule from '../ui.js';
|
||||||
@@ -24,11 +24,157 @@ function _safeHttpHref(raw) {
|
|||||||
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||||
let _rerollPane = null;
|
let _rerollPane = null;
|
||||||
let _autoPreviewHtml = null;
|
let _autoPreviewHtml = null;
|
||||||
|
let _setSendBtn = null;
|
||||||
|
|
||||||
/** Register external functions that live in compare.js. */
|
/** Register external functions that live in compare.js. */
|
||||||
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
|
function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
|
||||||
_rerollPane = rerollPane;
|
_rerollPane = rerollPane;
|
||||||
_autoPreviewHtml = autoPreviewHtml;
|
_autoPreviewHtml = autoPreviewHtml;
|
||||||
|
_setSendBtn = setSendBtn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _paneSessionIsCurrent(paneIdx, sessionId) {
|
||||||
|
return Boolean(
|
||||||
|
state.isActive
|
||||||
|
&& state._paneSessionIds[paneIdx] === sessionId
|
||||||
|
&& document.getElementById('cmp-history-' + paneIdx)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setCompareBusy(active) {
|
||||||
|
state._streaming = Boolean(active);
|
||||||
|
if (_setSendBtn) _setSendBtn(active ? 'stop' : 'send');
|
||||||
|
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
|
||||||
|
button.disabled = Boolean(active);
|
||||||
|
button.style.opacity = active ? '0.25' : '0.7';
|
||||||
|
button.style.pointerEvents = active ? 'none' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncCompareBusyFromPanes() {
|
||||||
|
_setCompareBusy((state._abortControllers || []).some(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _appendPaneMessage(hist, role, text) {
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
|
||||||
|
const roleEl = document.createElement('div');
|
||||||
|
roleEl.className = 'role';
|
||||||
|
roleEl.textContent = role === 'user' ? 'You' : 'AI';
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'body';
|
||||||
|
body.textContent = text || '';
|
||||||
|
message.appendChild(roleEl);
|
||||||
|
message.appendChild(body);
|
||||||
|
hist.appendChild(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _createPaneContinuationMessage(hist) {
|
||||||
|
const message = _appendPaneMessage(hist, 'assistant', '');
|
||||||
|
const body = message.querySelector('.body');
|
||||||
|
if (spinnerModule) {
|
||||||
|
const spinner = spinnerModule.create('Continuing...', 'right');
|
||||||
|
body.appendChild(spinner.createElement());
|
||||||
|
spinner.start();
|
||||||
|
message._spinner = spinner;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _restorePaneAskUserCard(paneIdx, sessionId, submission, originController) {
|
||||||
|
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||||
|
const restored = _renderPaneAskUserCard(
|
||||||
|
paneIdx,
|
||||||
|
sessionId,
|
||||||
|
submission.payload || {},
|
||||||
|
hist,
|
||||||
|
null,
|
||||||
|
originController,
|
||||||
|
);
|
||||||
|
if (uiModule) {
|
||||||
|
uiModule.showError(
|
||||||
|
restored
|
||||||
|
? 'This pane is still streaming — choose again once it settles.'
|
||||||
|
: 'Compare pane is still streaming; the choice was not sent.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resumePaneChoiceWhenIdle(paneIdx, sessionId, originController, submission) {
|
||||||
|
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return false;
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const resume = () => {
|
||||||
|
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return;
|
||||||
|
const activeController = state._abortControllers[paneIdx];
|
||||||
|
if (activeController === originController) {
|
||||||
|
if (Date.now() - startedAt < 10000) {
|
||||||
|
setTimeout(resume, 25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The originating stream never released the pane. The card was already
|
||||||
|
// removed when the choice was accepted, so put it back rather than
|
||||||
|
// swallowing a decision the user made.
|
||||||
|
_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A reroll/model replacement already owns this pane. Never send the stale
|
||||||
|
// choice into that replacement stream or session UI.
|
||||||
|
if (activeController) return;
|
||||||
|
|
||||||
|
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||||
|
if (!hist) return;
|
||||||
|
hist.querySelectorAll('.ask-user-card').forEach((card) => card.remove());
|
||||||
|
|
||||||
|
const isApproval = submission.kind === 'tool_approval';
|
||||||
|
const message = isApproval ? '' : String(submission.text || submission.label || '');
|
||||||
|
if (!isApproval) _appendPaneMessage(hist, 'user', message);
|
||||||
|
const aiMessage = _createPaneContinuationMessage(hist);
|
||||||
|
hist.scrollTop = hist.scrollHeight;
|
||||||
|
|
||||||
|
const resumeOptions = { skipBadge: true };
|
||||||
|
if (isApproval) {
|
||||||
|
resumeOptions.toolApproval = {
|
||||||
|
approval_id: String(submission.approval_id || ''),
|
||||||
|
decision: String(submission.decision || '').toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_setCompareBusy(true);
|
||||||
|
streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Compare pane continuation failed:', error);
|
||||||
|
if (uiModule) uiModule.showError('Compare continuation failed: ' + error.message);
|
||||||
|
})
|
||||||
|
.finally(_syncCompareBusyFromPanes);
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(resume, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderPaneAskUserCard(paneIdx, sessionId, payload, hist, aiMsgEl, originController) {
|
||||||
|
if (!hist || !hist.isConnected || !_paneSessionIsCurrent(paneIdx, sessionId)) return null;
|
||||||
|
if (aiMsgEl && aiMsgEl._spinner) {
|
||||||
|
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||||
|
aiMsgEl._spinner = null;
|
||||||
|
}
|
||||||
|
const card = renderAskUserCard(payload, {
|
||||||
|
root: hist,
|
||||||
|
onSubmit: (submission) => _resumePaneChoiceWhenIdle(
|
||||||
|
paneIdx,
|
||||||
|
sessionId,
|
||||||
|
originController,
|
||||||
|
submission,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (card) {
|
||||||
|
card.dataset.comparePane = String(paneIdx);
|
||||||
|
card.dataset.compareSession = String(sessionId);
|
||||||
|
}
|
||||||
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
|
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
|
||||||
@@ -164,6 +310,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
let metrics = null;
|
let metrics = null;
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let streamOk = false;
|
let streamOk = false;
|
||||||
|
let awaitingChoice = false;
|
||||||
let currentToolBlock = null; // track active agent tool block
|
let currentToolBlock = null; // track active agent tool block
|
||||||
// Idle timeout — abort only if no data is received for this many seconds.
|
// Idle timeout — abort only if no data is received for this many seconds.
|
||||||
// Long generations (SVG, big code) are fine as long as the stream stays
|
// Long generations (SVG, big code) are fine as long as the stream stays
|
||||||
@@ -219,6 +366,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('message', message);
|
fd.append('message', message);
|
||||||
fd.append('session', sessionId);
|
fd.append('session', sessionId);
|
||||||
|
if (opts.toolApproval) {
|
||||||
|
fd.append('tool_approval_id', opts.toolApproval.approval_id || '');
|
||||||
|
fd.append('tool_approval_decision', opts.toolApproval.decision || '');
|
||||||
|
}
|
||||||
|
|
||||||
// Compare mode determines what tools/features are enabled
|
// Compare mode determines what tools/features are enabled
|
||||||
const isAgent = state._compareMode === 'agent';
|
const isAgent = state._compareMode === 'agent';
|
||||||
@@ -322,6 +473,36 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pane-local question / approval selector ──
|
||||||
|
} else if (json.type === 'ask_user') {
|
||||||
|
awaitingChoice = true;
|
||||||
|
_renderPaneAskUserCard(
|
||||||
|
paneIdx,
|
||||||
|
sessionId,
|
||||||
|
json.data || {},
|
||||||
|
hist,
|
||||||
|
aiMsgEl,
|
||||||
|
ac,
|
||||||
|
);
|
||||||
|
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||||
|
|
||||||
|
// Deny ends as a tiny resolution-only stream, so replace the
|
||||||
|
// continuation spinner with an explicit pane-local result.
|
||||||
|
} else if (json.type === 'tool_approval_resolved') {
|
||||||
|
if (aiMsgEl._spinner) {
|
||||||
|
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||||
|
aiMsgEl._spinner = null;
|
||||||
|
}
|
||||||
|
accumulated = json.decision === 'deny' ? 'Denied.' : 'Approval recorded.';
|
||||||
|
let target = aiMsgEl._textEl;
|
||||||
|
if (!target) {
|
||||||
|
target = document.createElement('div');
|
||||||
|
target.className = 'compare-text-content';
|
||||||
|
aiBody.appendChild(target);
|
||||||
|
aiMsgEl._textEl = target;
|
||||||
|
}
|
||||||
|
target.textContent = accumulated;
|
||||||
|
|
||||||
// ── Tool start (bash, web search agent tool) ──
|
// ── Tool start (bash, web search agent tool) ──
|
||||||
} else if (json.type === 'tool_start') {
|
} else if (json.type === 'tool_start') {
|
||||||
// Finalize any accumulated text before the tool block
|
// Finalize any accumulated text before the tool block
|
||||||
@@ -640,19 +821,21 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
// TTFT removed from the header per user request — just show total time.
|
// TTFT removed from the header per user request — just show total time.
|
||||||
_timerEl.textContent = _formatMs(_totalMs);
|
_timerEl.textContent = _formatMs(_totalMs);
|
||||||
}
|
}
|
||||||
state._abortControllers[paneIdx] = null;
|
if (state._abortControllers[paneIdx] === ac) {
|
||||||
|
state._abortControllers[paneIdx] = null;
|
||||||
|
}
|
||||||
// Hide stop button, show response action buttons
|
// Hide stop button, show response action buttons
|
||||||
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||||
if (_paneElFinal) {
|
if (_paneElFinal) {
|
||||||
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
||||||
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
||||||
if (accumulated.trim()) {
|
if (!awaitingChoice && accumulated.trim()) {
|
||||||
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state._paneMetrics[paneIdx] = metrics;
|
state._paneMetrics[paneIdx] = metrics;
|
||||||
state._paneElapsed[paneIdx] = _totalMs;
|
state._paneElapsed[paneIdx] = _totalMs;
|
||||||
if (!opts.skipBadge) {
|
if (!opts.skipBadge && !awaitingChoice) {
|
||||||
if (streamOk) {
|
if (streamOk) {
|
||||||
state._finishOrder++;
|
state._finishOrder++;
|
||||||
if (state._parallel) {
|
if (state._parallel) {
|
||||||
@@ -682,7 +865,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
|
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
|
||||||
if (streamOk && state._expectedAnswer) {
|
if (streamOk && !awaitingChoice && state._expectedAnswer) {
|
||||||
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
|
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
|
||||||
}
|
}
|
||||||
// Show copy/reroll buttons now that response exists
|
// Show copy/reroll buttons now that response exists
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let _getPlatform;
|
|||||||
let _serverByVal;
|
let _serverByVal;
|
||||||
let _isWindows;
|
let _isWindows;
|
||||||
let _buildEnvPrefix;
|
let _buildEnvPrefix;
|
||||||
|
let _psQuote;
|
||||||
let _buildServeCmd;
|
let _buildServeCmd;
|
||||||
let _detectBackend;
|
let _detectBackend;
|
||||||
let _detectToolParser;
|
let _detectToolParser;
|
||||||
@@ -538,7 +539,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
|||||||
if (srv.downloadDir) payload.local_dir = srv.downloadDir;
|
if (srv.downloadDir) payload.local_dir = srv.downloadDir;
|
||||||
if (isWin) {
|
if (isWin) {
|
||||||
if (env === 'venv' && envPath) {
|
if (env === 'venv' && envPath) {
|
||||||
payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
|
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
|
||||||
} else if (env === 'conda' && envPath) {
|
} else if (env === 'conda' && envPath) {
|
||||||
payload.env_prefix = 'conda activate ' + envPath;
|
payload.env_prefix = 'conda activate ' + envPath;
|
||||||
}
|
}
|
||||||
@@ -652,6 +653,7 @@ export function initDownload(shared) {
|
|||||||
_serverByVal = shared._serverByVal;
|
_serverByVal = shared._serverByVal;
|
||||||
_isWindows = shared._isWindows;
|
_isWindows = shared._isWindows;
|
||||||
_buildEnvPrefix = shared._buildEnvPrefix;
|
_buildEnvPrefix = shared._buildEnvPrefix;
|
||||||
|
_psQuote = shared._psQuote;
|
||||||
_buildServeCmd = shared._buildServeCmd;
|
_buildServeCmd = shared._buildServeCmd;
|
||||||
_detectBackend = shared._detectBackend;
|
_detectBackend = shared._detectBackend;
|
||||||
_detectToolParser = shared._detectToolParser;
|
_detectToolParser = shared._detectToolParser;
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ let _sshPrefix;
|
|||||||
let _getPlatform;
|
let _getPlatform;
|
||||||
let _isWindows;
|
let _isWindows;
|
||||||
let _buildEnvPrefix;
|
let _buildEnvPrefix;
|
||||||
|
let _psQuote;
|
||||||
let _loadPresets;
|
let _loadPresets;
|
||||||
let _savePresets;
|
let _savePresets;
|
||||||
let _copyText;
|
let _copyText;
|
||||||
@@ -1971,7 +1972,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
|||||||
let envPrefix = '';
|
let envPrefix = '';
|
||||||
if (_isWindows()) {
|
if (_isWindows()) {
|
||||||
if (_envState.env === 'venv' && _envState.envPath) {
|
if (_envState.env === 'venv' && _envState.envPath) {
|
||||||
envPrefix = '& ' + (_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
||||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||||
envPrefix = 'conda activate ' + _envState.envPath;
|
envPrefix = 'conda activate ' + _envState.envPath;
|
||||||
}
|
}
|
||||||
@@ -4402,6 +4403,7 @@ export function initRunning(shared) {
|
|||||||
_getPlatform = shared._getPlatform;
|
_getPlatform = shared._getPlatform;
|
||||||
_isWindows = shared._isWindows;
|
_isWindows = shared._isWindows;
|
||||||
_buildEnvPrefix = shared._buildEnvPrefix;
|
_buildEnvPrefix = shared._buildEnvPrefix;
|
||||||
|
_psQuote = shared._psQuote;
|
||||||
_loadPresets = shared._loadPresets;
|
_loadPresets = shared._loadPresets;
|
||||||
_savePresets = shared._savePresets;
|
_savePresets = shared._savePresets;
|
||||||
_copyText = shared._copyText;
|
_copyText = shared._copyText;
|
||||||
|
|||||||
@@ -9738,6 +9738,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
|
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
// This container is detached, so the document-scoped flush mdToHtml
|
||||||
|
// schedules never sees it. Typeset the deferred math before html2pdf
|
||||||
|
// rasterises, or the PDF gets raw formula source. renderMath() returns
|
||||||
|
// immediately, without loading KaTeX, when there is nothing pending.
|
||||||
|
await markdownModule.renderMath(container);
|
||||||
const baseName = _getExportBaseName();
|
const baseName = _getExportBaseName();
|
||||||
window.html2pdf().set({
|
window.html2pdf().set({
|
||||||
margin: 10,
|
margin: 10,
|
||||||
|
|||||||
+202
-63
@@ -10,6 +10,127 @@ import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js'
|
|||||||
|
|
||||||
var escapeHtml = uiModule.esc;
|
var escapeHtml = uiModule.esc;
|
||||||
|
|
||||||
|
// Mermaid and KaTeX are vendored under /static/lib and fetched on first use.
|
||||||
|
// Loading them from <head> cost every session ~985 KB on the wire even though
|
||||||
|
// most chats never contain a diagram or a formula. Both loaders memoise the
|
||||||
|
// *promise* rather than the resolved library, so concurrent callers share one
|
||||||
|
// fetch and a double trigger cannot start two loads. A failed load clears the
|
||||||
|
// memo so the next diagram/formula retries instead of being poisoned forever.
|
||||||
|
const MERMAID_SRC = '/static/lib/mermaid.min.js';
|
||||||
|
const KATEX_SRC = '/static/lib/katex/katex.min.js';
|
||||||
|
const KATEX_CSS = '/static/lib/katex/katex.min.css';
|
||||||
|
// Marks math emitted before KaTeX finished loading; renderMath() swaps these
|
||||||
|
// for typeset output. The source stays as readable text inside the span, so a
|
||||||
|
// load that never completes degrades to plain text rather than to nothing.
|
||||||
|
const MATH_PENDING_CLASS = 'ody-math-pending';
|
||||||
|
|
||||||
|
// KaTeX has no entity syntax: it reads a bare "&" as an alignment marker and
|
||||||
|
// errors out on anything that is not a valid column break, so "a < b" comes
|
||||||
|
// back as a red .katex-error instead of a formula. mdToHtml escapes the whole
|
||||||
|
// string before the math pass, which leaves two spellings of the same
|
||||||
|
// character at the delimiters — a typed "<" arrives as "<", while a typed
|
||||||
|
// "<" arrives as "&lt;" — and both have to reach KaTeX as "<".
|
||||||
|
//
|
||||||
|
// One alternation, longest form first, so nothing this writes is scanned
|
||||||
|
// again. Chained .replace() calls cannot do it: unescaping "&" first lets
|
||||||
|
// the next pass eat the "<" it just produced (the double-unescape CodeQL
|
||||||
|
// flags), and unescaping it last leaves the entity spelling intact and breaks
|
||||||
|
// the render. The code-block pass upstream keeps its chained order on purpose
|
||||||
|
// — Markdown does not decode entities inside code, so "<" there is meant to
|
||||||
|
// stay visible.
|
||||||
|
const MATH_SOURCE_ENTITY_RE = /&(?:lt|gt|amp|quot|#39);|<|>|&/g;
|
||||||
|
const MATH_SOURCE_ENTITIES = {
|
||||||
|
'&lt;': '<',
|
||||||
|
'&gt;': '>',
|
||||||
|
'&amp;': '&',
|
||||||
|
'&quot;': '"',
|
||||||
|
'&#39;': "'",
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'&': '&',
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeMathSource(text) {
|
||||||
|
return String(text).replace(MATH_SOURCE_ENTITY_RE, (entity) => MATH_SOURCE_ENTITIES[entity]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _mermaidPromise = null;
|
||||||
|
let _katexPromise = null;
|
||||||
|
let _mathFlushScheduled = false;
|
||||||
|
|
||||||
|
function _loadScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.addEventListener('load', () => resolve(), { once: true });
|
||||||
|
script.addEventListener('error', () => reject(new Error('Failed to load ' + src)), { once: true });
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadStylesheet(href) {
|
||||||
|
// Resolves either way: without the stylesheet KaTeX still produces correct
|
||||||
|
// markup, just unstyled, which beats failing the whole math render.
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = href;
|
||||||
|
link.addEventListener('load', () => resolve(), { once: true });
|
||||||
|
link.addEventListener('error', () => resolve(), { once: true });
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load Mermaid on first use and initialize it once.
|
||||||
|
*/
|
||||||
|
export function ensureMermaid() {
|
||||||
|
return (_mermaidPromise ??= _loadScript(MERMAID_SRC)
|
||||||
|
.then(() => {
|
||||||
|
if (!window.mermaid) throw new Error('mermaid global missing after load');
|
||||||
|
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
|
||||||
|
return window.mermaid;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
_mermaidPromise = null;
|
||||||
|
throw err;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load KaTeX (script + stylesheet) on first use.
|
||||||
|
*/
|
||||||
|
export function ensureKatex() {
|
||||||
|
return (_katexPromise ??= Promise.all([_loadScript(KATEX_SRC), _loadStylesheet(KATEX_CSS)])
|
||||||
|
.then(() => {
|
||||||
|
if (!window.katex) throw new Error('katex global missing after load');
|
||||||
|
return window.katex;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
_katexPromise = null;
|
||||||
|
throw err;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdToHtml() is synchronous and its callers insert the returned string into the
|
||||||
|
// DOM themselves, so the placeholders are usually not attached yet when this
|
||||||
|
// fires. Loading first and scanning afterwards covers that gap: by the time
|
||||||
|
// KaTeX is in, the caller's innerHTML assignment has long since happened.
|
||||||
|
//
|
||||||
|
// setTimeout, not requestAnimationFrame: this has nothing to do with paint, and
|
||||||
|
// rAF is throttled to a stop in a background tab (and never fires at all in a
|
||||||
|
// headless browser), which would leave math untypeset until the tab is focused.
|
||||||
|
function _scheduleMathFlush() {
|
||||||
|
if (_mathFlushScheduled) return;
|
||||||
|
_mathFlushScheduled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
_mathFlushScheduled = false;
|
||||||
|
ensureKatex()
|
||||||
|
.then(() => renderMath(document))
|
||||||
|
.catch((e) => console.warn('KaTeX load error:', e));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function safeLinkUrl(rawUrl) {
|
function safeLinkUrl(rawUrl) {
|
||||||
const url = String(rawUrl || '').trim();
|
const url = String(rawUrl || '').trim();
|
||||||
if (url.startsWith('#')) {
|
if (url.startsWith('#')) {
|
||||||
@@ -631,49 +752,45 @@ export function mdToHtml(src, opts) {
|
|||||||
|
|
||||||
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
|
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
|
||||||
const mathBlocks = [];
|
const mathBlocks = [];
|
||||||
if (window.katex) {
|
let sawPendingMath = false;
|
||||||
// Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.).
|
|
||||||
// Handle before $$/$ so all common delimiters render.
|
// Typeset straight away when KaTeX is already in, otherwise bank the source in
|
||||||
s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
|
// an inert placeholder for renderMath() to swap once the library lands.
|
||||||
try {
|
const pushMath = (math, displayMode) => {
|
||||||
const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
const raw = decodeMathSource(math).trim();
|
||||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
||||||
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false }));
|
if (window.katex) {
|
||||||
return placeholder;
|
mathBlocks.push(katex.renderToString(raw, { displayMode, throwOnError: false }));
|
||||||
} catch (e) { return match; }
|
} else {
|
||||||
});
|
sawPendingMath = true;
|
||||||
// Inline math: \( ... \) — GPT-style inline delimiter. Single-line only
|
mathBlocks.push(`<span class="${MATH_PENDING_CLASS}" data-display="${displayMode}">${escapeHtml(raw)}</span>`);
|
||||||
// ([^\n]) so a stray escaped paren in prose can't swallow across lines.
|
}
|
||||||
s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => {
|
return placeholder;
|
||||||
try {
|
};
|
||||||
const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
// Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.).
|
||||||
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false }));
|
// Handle before $$/$ so all common delimiters render.
|
||||||
return placeholder;
|
s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
|
||||||
} catch (e) { return match; }
|
try { return pushMath(math, true); } catch (e) { return match; }
|
||||||
});
|
});
|
||||||
// Display math: $$...$$
|
// Inline math: \( ... \) — GPT-style inline delimiter. Single-line only
|
||||||
s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => {
|
// ([^\n]) so a stray escaped paren in prose can't swallow across lines.
|
||||||
try {
|
s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => {
|
||||||
const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
try { return pushMath(math, false); } catch (e) { return match; }
|
||||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
});
|
||||||
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false }));
|
// Display math: $$...$$
|
||||||
return placeholder;
|
s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => {
|
||||||
} catch (e) { return match; }
|
try { return pushMath(math, true); } catch (e) { return match; }
|
||||||
});
|
});
|
||||||
// Inline math: $...$ — single line only, and Pandoc-style delimiter rules so
|
// Inline math: $...$ — single line only, and Pandoc-style delimiter rules so
|
||||||
// currency doesn't render as math ("$5 to $10"): the opening $ must be
|
// currency doesn't render as math ("$5 to $10"): the opening $ must be
|
||||||
// immediately followed by a non-space, the closing $ must be immediately
|
// immediately followed by a non-space, the closing $ must be immediately
|
||||||
// preceded by a non-space and not followed by a digit.
|
// preceded by a non-space and not followed by a digit.
|
||||||
s = s.replace(/(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)/g, (match, math) => {
|
s = s.replace(/(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)/g, (match, math) => {
|
||||||
try {
|
try { return pushMath(math, false); } catch (e) { return match; }
|
||||||
const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
});
|
||||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
|
||||||
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false }));
|
if (sawPendingMath) _scheduleMathFlush();
|
||||||
return placeholder;
|
|
||||||
} catch (e) { return match; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle pipe tables
|
// Handle pipe tables
|
||||||
s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => {
|
s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => {
|
||||||
@@ -826,19 +943,47 @@ export function renderContent(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize any unprocessed Mermaid diagrams in a container (or whole document)
|
* Initialize any unprocessed Mermaid diagrams in a container (or whole document).
|
||||||
|
* Returns a promise so callers can await the (lazy) library load if they need to.
|
||||||
*/
|
*/
|
||||||
export function renderMermaid(container) {
|
export function renderMermaid(container) {
|
||||||
if (!window.mermaid) return;
|
|
||||||
initMermaid();
|
|
||||||
const target = container || document;
|
const target = container || document;
|
||||||
const pending = target.querySelectorAll('pre.mermaid:not([data-processed])');
|
if (!target || typeof target.querySelectorAll !== 'function') return Promise.resolve();
|
||||||
if (pending.length === 0) return;
|
// Cheap pre-check: no fence on the page means Mermaid is never fetched.
|
||||||
try {
|
if (target.querySelectorAll('pre.mermaid:not([data-processed])').length === 0) return Promise.resolve();
|
||||||
window.mermaid.run({ nodes: pending });
|
return ensureMermaid()
|
||||||
} catch (e) {
|
.then((mermaid) => {
|
||||||
console.warn('Mermaid render error:', e);
|
// Re-query after the load: during streaming the renderer replaces the
|
||||||
}
|
// message body repeatedly, so the nodes seen before the fetch are stale.
|
||||||
|
const nodes = [...target.querySelectorAll('pre.mermaid:not([data-processed])')]
|
||||||
|
.filter((node) => node.isConnected);
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
return mermaid.run({ nodes });
|
||||||
|
})
|
||||||
|
.catch((e) => { console.warn('Mermaid render error:', e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typeset any math that mdToHtml() had to defer because KaTeX was not loaded
|
||||||
|
* yet. Once KaTeX is in, mdToHtml() renders inline and this finds nothing.
|
||||||
|
*/
|
||||||
|
export function renderMath(container) {
|
||||||
|
const target = container || document;
|
||||||
|
if (!target || typeof target.querySelectorAll !== 'function') return Promise.resolve();
|
||||||
|
if (target.querySelectorAll('.' + MATH_PENDING_CLASS).length === 0) return Promise.resolve();
|
||||||
|
return ensureKatex()
|
||||||
|
.then((katex) => {
|
||||||
|
target.querySelectorAll('.' + MATH_PENDING_CLASS).forEach((el) => {
|
||||||
|
const displayMode = el.getAttribute('data-display') === 'true';
|
||||||
|
try {
|
||||||
|
el.outerHTML = katex.renderToString(el.textContent || '', { displayMode, throwOnError: false });
|
||||||
|
} catch (e) {
|
||||||
|
// Leave the source visible — readable, just not typeset.
|
||||||
|
el.classList.remove(MATH_PENDING_CLASS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => { console.warn('KaTeX render error:', e); });
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownModule = {
|
const markdownModule = {
|
||||||
@@ -853,20 +998,14 @@ const markdownModule = {
|
|||||||
extractThinkingBlocks,
|
extractThinkingBlocks,
|
||||||
normalizeThinkingMarkup,
|
normalizeThinkingMarkup,
|
||||||
startsWithReasoningPrefix,
|
startsWithReasoningPrefix,
|
||||||
renderMermaid
|
renderMermaid,
|
||||||
|
renderMath,
|
||||||
|
ensureMermaid,
|
||||||
|
ensureKatex
|
||||||
};
|
};
|
||||||
|
|
||||||
export default markdownModule;
|
export default markdownModule;
|
||||||
|
|
||||||
// Mermaid is loaded async so it cannot delay the app shell.
|
|
||||||
function initMermaid() {
|
|
||||||
if (!window.mermaid || window.__odysseusMermaidReady) return;
|
|
||||||
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
|
|
||||||
window.__odysseusMermaidReady = true;
|
|
||||||
}
|
|
||||||
window.odysseusInitMermaid = initMermaid;
|
|
||||||
initMermaid();
|
|
||||||
|
|
||||||
// Persist which thinking sections were expanded across page refreshes.
|
// Persist which thinking sections were expanded across page refreshes.
|
||||||
// IDs are render-generated (Date.now-based) so we key by a stable hash of
|
// IDs are render-generated (Date.now-based) so we key by a stable hash of
|
||||||
// the inner text content instead — same content reproduces the same hash on
|
// the inner text content instead — same content reproduces the same hash on
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+3587
File diff suppressed because one or more lines are too long
+24
-1
@@ -7,7 +7,21 @@
|
|||||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||||
// - API / non-GET: never cached.
|
// - API / non-GET: never cached.
|
||||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||||
const CACHE_NAME = 'odysseus-v378-shared-config-image-editor';
|
const CACHE_NAME = 'odysseus-v380-shared-config-image-editor-lazy-katex-mermaid';
|
||||||
|
|
||||||
|
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||||
|
// gives offline math fallback glyphs instead of proper typesetting.
|
||||||
|
const KATEX_FONTS = [
|
||||||
|
'AMS-Regular', 'Caligraphic-Bold', 'Caligraphic-Regular',
|
||||||
|
'Fraktur-Bold', 'Fraktur-Regular',
|
||||||
|
'Main-Bold', 'Main-BoldItalic', 'Main-Italic', 'Main-Regular',
|
||||||
|
'Math-BoldItalic', 'Math-Italic',
|
||||||
|
'SansSerif-Bold', 'SansSerif-Italic', 'SansSerif-Regular',
|
||||||
|
'Script-Regular',
|
||||||
|
'Size1-Regular', 'Size2-Regular', 'Size3-Regular', 'Size4-Regular',
|
||||||
|
'Typewriter-Regular',
|
||||||
|
].map(name => `/static/lib/katex/fonts/KaTeX_${name}.woff2`);
|
||||||
|
|
||||||
|
|
||||||
// Two lists, two jobs — they are no longer the same set and must not be
|
// Two lists, two jobs — they are no longer the same set and must not be
|
||||||
// "resynced" back into one:
|
// "resynced" back into one:
|
||||||
@@ -73,6 +87,15 @@ const PRECACHE = [
|
|||||||
'/static/js/sidebar-layout.js',
|
'/static/js/sidebar-layout.js',
|
||||||
'/static/js/section-management.js',
|
'/static/js/section-management.js',
|
||||||
'/static/lib/highlight.min.js',
|
'/static/lib/highlight.min.js',
|
||||||
|
// Math turns up in ordinary answers and KaTeX is small, so precaching it and
|
||||||
|
// its fonts keeps formulas typeset offline. Mermaid is deliberately NOT
|
||||||
|
// precached: at 3.5 MB it would re-download on every CACHE_NAME bump, a poor
|
||||||
|
// trade for a library most sessions never touch. The cache-first rule below
|
||||||
|
// picks it up the first time a diagram renders, which is also when it starts
|
||||||
|
// mattering offline.
|
||||||
|
'/static/lib/katex/katex.min.js',
|
||||||
|
'/static/lib/katex/katex.min.css',
|
||||||
|
...KATEX_FONTS,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
|
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
|
||||||
|
|||||||
@@ -10,3 +10,28 @@ def test_mask_token_handles_short_values(monkeypatch):
|
|||||||
assert cli._mask_token("short") == "***"
|
assert cli._mask_token("short") == "***"
|
||||||
assert cli._mask_token("abcdef1234567890") == "abcdef…7890"
|
assert cli._mask_token("abcdef1234567890") == "abcdef…7890"
|
||||||
assert cli._mask_token("short", reveal=True) == "short"
|
assert cli._mask_token("short", reveal=True) == "short"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_webhook_url_matches_live_route_and_escapes_path_parts(monkeypatch):
|
||||||
|
make_core_db_stub(monkeypatch, models=["ScheduledTask"])
|
||||||
|
cli = load_script("odysseus-webhook")
|
||||||
|
|
||||||
|
url = cli._task_webhook_url(
|
||||||
|
"https://ody.example/",
|
||||||
|
"task/with space",
|
||||||
|
"token/with space",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert url == (
|
||||||
|
"https://ody.example/api/tasks/task%2Fwith%20space/"
|
||||||
|
"webhook/token%2Fwith%20space"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_task_webhook_url_uses_current_task_prefix(monkeypatch):
|
||||||
|
make_core_db_stub(monkeypatch, models=["ScheduledTask"])
|
||||||
|
cli = load_script("odysseus-webhook")
|
||||||
|
|
||||||
|
assert cli._task_webhook_url(None, "task-1", "secret") == (
|
||||||
|
"http://localhost:7000/api/tasks/task-1/webhook/secret"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Regression coverage for the assistant turn an approved-action replay appends.
|
||||||
|
|
||||||
|
Anthropic's Messages API rejects a non-final assistant message whose content is
|
||||||
|
empty, so the resumed turn after a tool approval used to fail before the model
|
||||||
|
ever saw the sealed result. The replay injects its result with no assistant
|
||||||
|
prose for that round, which is the only path that produced such a turn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import src.llm_core as llm_core
|
||||||
|
from src.agent_loop import _append_tool_results
|
||||||
|
|
||||||
|
_RESULT = "bash: ok\nhello"
|
||||||
|
_RECORD = {
|
||||||
|
"tool_name": "bash",
|
||||||
|
"content": "printf hello",
|
||||||
|
"result": {"output": "hello", "exit_code": 0},
|
||||||
|
"text": _RESULT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_messages(round_response="", round_reasoning=""):
|
||||||
|
"""Mirror the approved-action injection in stream_agent_loop."""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "system preface"},
|
||||||
|
{"role": "user", "content": "run the command and summarise it"},
|
||||||
|
]
|
||||||
|
_append_tool_results(
|
||||||
|
messages,
|
||||||
|
round_response,
|
||||||
|
[],
|
||||||
|
[_RESULT],
|
||||||
|
[_RESULT],
|
||||||
|
False,
|
||||||
|
0,
|
||||||
|
round_reasoning=round_reasoning,
|
||||||
|
tool_result_records=[_RECORD],
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_assistant_turns(messages):
|
||||||
|
return [
|
||||||
|
index
|
||||||
|
for index, message in enumerate(messages)
|
||||||
|
if message.get("role") == "assistant"
|
||||||
|
and not str(message.get("content") or "").strip()
|
||||||
|
and not message.get("tool_calls")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_appends_no_empty_assistant_turn():
|
||||||
|
assert _empty_assistant_turns(_replay_messages()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_payload_is_a_single_non_empty_user_turn_for_anthropic():
|
||||||
|
# Asserting the whole sequence rather than scanning a slice: once the empty
|
||||||
|
# spacer is gone the payload is one message, so a "no offenders in
|
||||||
|
# chat[:-1]" check would pass without inspecting anything.
|
||||||
|
sanitized = llm_core._sanitize_llm_messages(_replay_messages())
|
||||||
|
payload = llm_core._build_anthropic_payload(
|
||||||
|
"claude-sonnet-5", sanitized, 0.2, 512
|
||||||
|
)
|
||||||
|
chat = payload["messages"]
|
||||||
|
assert [message["role"] for message in chat] == ["user"]
|
||||||
|
assert all(str(message.get("content") or "").strip() for message in chat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_merges_tool_output_after_the_request_with_its_fence_intact():
|
||||||
|
# Dropping the empty assistant turn leaves two adjacent user messages, which
|
||||||
|
# the sanitizer merges. Pin that shape: the tool output must still sit
|
||||||
|
# behind its untrusted fence and must not precede the operator's request.
|
||||||
|
sanitized = llm_core._sanitize_llm_messages(_replay_messages())
|
||||||
|
user_turns = [m for m in sanitized if m.get("role") == "user"]
|
||||||
|
assert len(user_turns) == 1
|
||||||
|
merged = user_turns[0]["content"]
|
||||||
|
assert merged.index("run the command") < merged.index("UNTRUSTED SOURCE DATA")
|
||||||
|
assert merged.index("UNTRUSTED SOURCE DATA") < merged.index("hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_round_with_prose_still_appends_its_assistant_turn():
|
||||||
|
messages = _replay_messages(round_response="running that now")
|
||||||
|
assistants = [m for m in messages if m.get("role") == "assistant"]
|
||||||
|
assert [m["content"] for m in assistants] == ["running that now"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_only_round_keeps_its_carrier_and_its_known_empty_content():
|
||||||
|
"""Characterises the one empty-content turn this change deliberately leaves.
|
||||||
|
|
||||||
|
A round with reasoning and no prose still appends its carrier, and that
|
||||||
|
carrier's content is still "", which Anthropic would still reject. Dropping
|
||||||
|
it would lose the reasoning DeepSeek thinking mode requires on the next
|
||||||
|
request, and the approval replay never takes this path because it passes no
|
||||||
|
reasoning. Left alone on purpose; this test makes the gap visible instead of
|
||||||
|
silent, and should be updated by whoever closes it.
|
||||||
|
"""
|
||||||
|
messages = _replay_messages(round_reasoning="thinking about it")
|
||||||
|
assistants = [m for m in messages if m.get("role") == "assistant"]
|
||||||
|
assert len(assistants) == 1
|
||||||
|
assert assistants[0].get("reasoning_content") == "thinking about it"
|
||||||
|
assert assistants[0]["content"] == ""
|
||||||
+45
-4
@@ -123,7 +123,7 @@ def test_atomic_write_json_concurrent_writers_do_not_collide(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# atomic_write_json — failure path: target preserved on serialization error.
|
# atomic_write_json — failure paths
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def test_atomic_write_json_preserves_target_when_serialization_fails(tmp_path):
|
def test_atomic_write_json_preserves_target_when_serialization_fails(tmp_path):
|
||||||
target = tmp_path / "data.json"
|
target = tmp_path / "data.json"
|
||||||
@@ -136,6 +136,26 @@ def test_atomic_write_json_preserves_target_when_serialization_fails(tmp_path):
|
|||||||
atomic_write_json(str(target), {"bad": {1, 2, 3}})
|
atomic_write_json(str(target), {"bad": {1, 2, 3}})
|
||||||
|
|
||||||
assert target.read_text(encoding="utf-8") == before
|
assert target.read_text(encoding="utf-8") == before
|
||||||
|
# Temp file should be cleaned up
|
||||||
|
assert _tmp_siblings(tmp_path, "data.json") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_write_json_preserves_target_when_replace_fails(tmp_path, monkeypatch):
|
||||||
|
target = tmp_path / "data.json"
|
||||||
|
atomic_write_json(str(target), {"existing": "value"})
|
||||||
|
before = target.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def boom(src, dst):
|
||||||
|
raise PermissionError("replace failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(atomic_io.os, "replace", boom)
|
||||||
|
|
||||||
|
with pytest.raises(PermissionError, match="replace failed"):
|
||||||
|
atomic_write_json(str(target), {"new": "content"})
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == before
|
||||||
|
# Temp file should be cleaned up
|
||||||
|
assert _tmp_siblings(tmp_path, "data.json") == []
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -187,7 +207,7 @@ def test_atomic_write_text_rejects_non_string_before_tmp_file(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# atomic_write_text — failure path: target preserved when replace fails.
|
# atomic_write_text — failure paths
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def test_atomic_write_text_preserves_target_when_replace_fails(tmp_path, monkeypatch):
|
def test_atomic_write_text_preserves_target_when_replace_fails(tmp_path, monkeypatch):
|
||||||
target = tmp_path / "note.txt"
|
target = tmp_path / "note.txt"
|
||||||
@@ -195,11 +215,32 @@ def test_atomic_write_text_preserves_target_when_replace_fails(tmp_path, monkeyp
|
|||||||
before = target.read_text(encoding="utf-8")
|
before = target.read_text(encoding="utf-8")
|
||||||
|
|
||||||
def boom(src, dst):
|
def boom(src, dst):
|
||||||
raise OSError("replace failed")
|
raise PermissionError("replace failed")
|
||||||
|
|
||||||
monkeypatch.setattr(atomic_io.os, "replace", boom)
|
monkeypatch.setattr(atomic_io.os, "replace", boom)
|
||||||
|
|
||||||
with pytest.raises(OSError):
|
with pytest.raises(PermissionError, match="replace failed"):
|
||||||
atomic_write_text(str(target), "new content that never lands")
|
atomic_write_text(str(target), "new content that never lands")
|
||||||
|
|
||||||
assert target.read_text(encoding="utf-8") == before
|
assert target.read_text(encoding="utf-8") == before
|
||||||
|
# Temp file should be cleaned up
|
||||||
|
assert _tmp_siblings(tmp_path, "note.txt") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_error_swallows_and_preserves_original_exception(tmp_path, monkeypatch):
|
||||||
|
target = tmp_path / "note.txt"
|
||||||
|
atomic_write_text(str(target), "original content")
|
||||||
|
|
||||||
|
def replace_boom(src, dst):
|
||||||
|
raise PermissionError("replace failed")
|
||||||
|
|
||||||
|
def unlink_boom(path):
|
||||||
|
raise OSError("unlink failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(atomic_io.os, "replace", replace_boom)
|
||||||
|
monkeypatch.setattr(atomic_io.os, "unlink", unlink_boom)
|
||||||
|
|
||||||
|
# If BOTH the replace fails AND the cleanup unlink fails,
|
||||||
|
# the original replace error should surface, completely swallowing the unlink error.
|
||||||
|
with pytest.raises(PermissionError, match="replace failed"):
|
||||||
|
atomic_write_text(str(target), "new content")
|
||||||
@@ -99,7 +99,12 @@ def test_login_offloads_bcrypt_bearing_calls(monkeypatch):
|
|||||||
|
|
||||||
login = _login_endpoint(auth)
|
login = _login_endpoint(auth)
|
||||||
|
|
||||||
request = SimpleNamespace(client=SimpleNamespace(host="203.0.113.7"), cookies={})
|
request = SimpleNamespace(
|
||||||
|
client=SimpleNamespace(host="203.0.113.7"),
|
||||||
|
cookies={},
|
||||||
|
url=SimpleNamespace(scheme="http"),
|
||||||
|
headers={},
|
||||||
|
)
|
||||||
response = MagicMock()
|
response = MagicMock()
|
||||||
body = LoginRequest(username="alice", password="hunter2", remember=True)
|
body = LoginRequest(username="alice", password="hunter2", remember=True)
|
||||||
|
|
||||||
|
|||||||
@@ -242,12 +242,26 @@ def _login_endpoint(auth_manager):
|
|||||||
raise AssertionError("login route not found")
|
raise AssertionError("login route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _login_request(scheme="http", forwarded_proto=None):
|
||||||
|
"""Stand-in for fastapi.Request carrying the fields login reads: the
|
||||||
|
client host (rate limiter), and the URL scheme plus `X-Forwarded-Proto`
|
||||||
|
(cookie Secure flag)."""
|
||||||
|
headers = {}
|
||||||
|
if forwarded_proto is not None:
|
||||||
|
headers["x-forwarded-proto"] = forwarded_proto
|
||||||
|
return SimpleNamespace(
|
||||||
|
client=SimpleNamespace(host="127.0.0.1"),
|
||||||
|
url=SimpleNamespace(scheme=scheme),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_remember_cookie_max_age_matches_token_ttl(tmp_path):
|
def test_remember_cookie_max_age_matches_token_ttl(tmp_path):
|
||||||
auth_mod = _auth_module()
|
auth_mod = _auth_module()
|
||||||
mgr = _make_manager(tmp_path)
|
mgr = _make_manager(tmp_path)
|
||||||
mgr.create_user("alice", "alice-password", is_admin=False)
|
mgr.create_user("alice", "alice-password", is_admin=False)
|
||||||
endpoint, LoginRequest = _login_endpoint(mgr)
|
endpoint, LoginRequest = _login_endpoint(mgr)
|
||||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
request = _login_request()
|
||||||
response = _CapturingResponse()
|
response = _CapturingResponse()
|
||||||
body = LoginRequest(username="alice", password="alice-password", remember=True)
|
body = LoginRequest(username="alice", password="alice-password", remember=True)
|
||||||
|
|
||||||
@@ -262,7 +276,7 @@ def test_no_remember_omits_cookie_max_age(tmp_path):
|
|||||||
mgr = _make_manager(tmp_path)
|
mgr = _make_manager(tmp_path)
|
||||||
mgr.create_user("bob", "bob-password", is_admin=False)
|
mgr.create_user("bob", "bob-password", is_admin=False)
|
||||||
endpoint, LoginRequest = _login_endpoint(mgr)
|
endpoint, LoginRequest = _login_endpoint(mgr)
|
||||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
request = _login_request()
|
||||||
response = _CapturingResponse()
|
response = _CapturingResponse()
|
||||||
body = LoginRequest(username="bob", password="bob-password", remember=False)
|
body = LoginRequest(username="bob", password="bob-password", remember=False)
|
||||||
|
|
||||||
@@ -270,3 +284,80 @@ def test_no_remember_omits_cookie_max_age(tmp_path):
|
|||||||
|
|
||||||
# Without "remember", the cookie is a session cookie (no max_age).
|
# Without "remember", the cookie is a session cookie (no max_age).
|
||||||
assert "max_age" not in response.cookie_kwargs
|
assert "max_age" not in response.cookie_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session cookie Secure flag ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _login_secure_flag(tmp_path, scheme, forwarded_proto=None):
|
||||||
|
"""Log a user in over ``scheme`` and return the cookie's Secure flag."""
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
mgr.create_user("carol", "carol-password", is_admin=False)
|
||||||
|
endpoint, LoginRequest = _login_endpoint(mgr)
|
||||||
|
response = _CapturingResponse()
|
||||||
|
body = LoginRequest(username="carol", password="carol-password")
|
||||||
|
request = _login_request(scheme, forwarded_proto)
|
||||||
|
|
||||||
|
asyncio.run(endpoint(body=body, request=request, response=response))
|
||||||
|
|
||||||
|
return response.cookie_kwargs["secure"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_https_login_marks_cookie_secure_without_config(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("SECURE_COOKIES", raising=False)
|
||||||
|
|
||||||
|
# A session token handed out over HTTPS must not be allowed to travel
|
||||||
|
# back in cleartext just because nobody set an env var.
|
||||||
|
assert _login_secure_flag(tmp_path, "https") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_http_login_leaves_cookie_insecure_without_config(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("SECURE_COOKIES", raising=False)
|
||||||
|
|
||||||
|
# Marking it Secure here would make the browser drop the cookie and
|
||||||
|
# break login on a plain-HTTP install.
|
||||||
|
assert _login_secure_flag(tmp_path, "http") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_forwarded_proto_https_marks_cookie_secure(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("SECURE_COOKIES", raising=False)
|
||||||
|
|
||||||
|
# A terminator that is not on an address uvicorn trusts leaves the
|
||||||
|
# connection scheme as http, so the header is the only signal there.
|
||||||
|
assert _login_secure_flag(tmp_path, "http", forwarded_proto="https") is True
|
||||||
|
# Chained proxies send a list; the client-facing hop is the first entry.
|
||||||
|
assert _login_secure_flag(tmp_path, "http", forwarded_proto="https, http") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_forwarded_proto_http_leaves_plain_http_login_insecure(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("SECURE_COOKIES", raising=False)
|
||||||
|
|
||||||
|
# The header is not a downgrade switch: either signal saying https is
|
||||||
|
# enough, the same test core/middleware.py applies before sending HSTS.
|
||||||
|
assert _login_secure_flag(tmp_path, "http", forwarded_proto="http") is False
|
||||||
|
assert _login_secure_flag(tmp_path, "https", forwarded_proto="http") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_secure_cookies_still_derives_from_scheme(tmp_path, monkeypatch):
|
||||||
|
# docker-compose injects `SECURE_COOKIES=${SECURE_COOKIES:-}`, which sets
|
||||||
|
# the variable to "" when the host has not defined it. Empty means
|
||||||
|
# unconfigured, not "off".
|
||||||
|
monkeypatch.setenv("SECURE_COOKIES", "")
|
||||||
|
|
||||||
|
assert _login_secure_flag(tmp_path, "https") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_secure_cookies_true_forces_secure_on_plain_http(tmp_path, monkeypatch):
|
||||||
|
# The documented knob keeps working for a proxy whose scheme the app
|
||||||
|
# cannot see, e.g. one that is not on a trusted loopback address.
|
||||||
|
monkeypatch.setenv("SECURE_COOKIES", "true")
|
||||||
|
|
||||||
|
assert _login_secure_flag(tmp_path, "http") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_secure_cookies_false_forces_insecure_on_https(tmp_path, monkeypatch):
|
||||||
|
# The escape hatch for an install still answering on both HTTP and
|
||||||
|
# HTTPS: an explicit false wins over the request scheme.
|
||||||
|
monkeypatch.setenv("SECURE_COOKIES", "false")
|
||||||
|
|
||||||
|
assert _login_secure_flag(tmp_path, "https") is False
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def test_registered_manual_compaction_uses_session_owner_for_utility_endpoint():
|
|||||||
|
|
||||||
|
|
||||||
def test_task_name_generation_uses_owner_scoped_session_endpoint():
|
def test_task_name_generation_uses_owner_scoped_session_endpoint():
|
||||||
src = _src("routes/task_routes.py")
|
src = _src("routes/task/task_routes.py")
|
||||||
|
|
||||||
assert "async def _generate_task_name(prompt: str, owner: Optional[str] = None)" in src
|
assert "async def _generate_task_name(prompt: str, owner: Optional[str] = None)" in src
|
||||||
assert "q = q.filter(DbSession.owner == owner)" in src
|
assert "q = q.filter(DbSession.owner == owner)" in src
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Automatic chat URL fetch failures must remain visible to the model."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.chat_processor as chat_processor
|
||||||
|
|
||||||
|
|
||||||
|
def _processor():
|
||||||
|
return chat_processor.ChatProcessor(
|
||||||
|
memory_manager=SimpleNamespace(load=lambda owner=None: []),
|
||||||
|
personal_docs_manager=SimpleNamespace(rag_manager=None),
|
||||||
|
skills_manager=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_url_prefetch_keeps_diagnostics_out_of_model_context(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_processor,
|
||||||
|
"fetch_webpage_content",
|
||||||
|
lambda url: {
|
||||||
|
"success": False,
|
||||||
|
"error": "NetworkError: connection refused\nattacker supplied detail",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
preface, _, _ = _processor().build_context_preface(
|
||||||
|
message=(
|
||||||
|
"Please summarize "
|
||||||
|
"https://example.invalid/report?token=LEAK_URL_MARKER"
|
||||||
|
),
|
||||||
|
session=SimpleNamespace(endpoint_url="", model="", headers={}),
|
||||||
|
use_web=False,
|
||||||
|
use_rag=False,
|
||||||
|
use_memory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
failure = next(
|
||||||
|
message
|
||||||
|
for message in preface
|
||||||
|
if (message.get("metadata") or {}).get("source")
|
||||||
|
== "web page fetch failure"
|
||||||
|
)
|
||||||
|
assert failure["metadata"]["trusted"] is False
|
||||||
|
assert "was not read: the page was unavailable" in failure["content"]
|
||||||
|
assert "connection refused" not in failure["content"]
|
||||||
|
assert "attacker supplied detail" not in failure["content"]
|
||||||
|
assert "LEAK_URL_MARKER" not in failure["content"]
|
||||||
|
assert "LEAK_URL_MARKER" not in str(failure["metadata"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_url_prefetch_exposes_only_stable_http_status(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_processor,
|
||||||
|
"fetch_webpage_content",
|
||||||
|
lambda url: {
|
||||||
|
"success": False,
|
||||||
|
"error": "HTTP 403: attacker-controlled response detail",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
preface, _, _ = _processor().build_context_preface(
|
||||||
|
message="Read https://example.test/private",
|
||||||
|
session=SimpleNamespace(endpoint_url="", model="", headers={}),
|
||||||
|
use_web=False,
|
||||||
|
use_rag=False,
|
||||||
|
use_memory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = preface[-1]["content"]
|
||||||
|
assert "server returned HTTP 403" in content
|
||||||
|
assert "attacker-controlled" not in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("error", "expected_category"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"TooLarge: response exceeded configured limit; attacker-controlled detail",
|
||||||
|
"response exceeded the fetch size limit",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Rate limit exceeded: attacker-controlled detail",
|
||||||
|
"request was rate limited",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_failed_url_prefetch_exposes_only_stable_failure_category(
|
||||||
|
monkeypatch, error, expected_category
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_processor,
|
||||||
|
"fetch_webpage_content",
|
||||||
|
lambda url: {"success": False, "error": error},
|
||||||
|
)
|
||||||
|
|
||||||
|
preface, _, _ = _processor().build_context_preface(
|
||||||
|
message="Read https://example.test/report",
|
||||||
|
session=SimpleNamespace(endpoint_url="", model="", headers={}),
|
||||||
|
use_web=False,
|
||||||
|
use_rag=False,
|
||||||
|
use_memory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = preface[-1]["content"]
|
||||||
|
assert expected_category in content
|
||||||
|
assert "attacker-controlled" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_url_prefetch_exception_does_not_abort_context(monkeypatch):
|
||||||
|
def fail(url):
|
||||||
|
raise RuntimeError("socket detail must stay in logs")
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_processor, "fetch_webpage_content", fail)
|
||||||
|
|
||||||
|
preface, _, _ = _processor().build_context_preface(
|
||||||
|
message="Review https://example.test/report",
|
||||||
|
session=SimpleNamespace(endpoint_url="", model="", headers={}),
|
||||||
|
use_web=False,
|
||||||
|
use_rag=False,
|
||||||
|
use_memory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = preface[-1]["content"]
|
||||||
|
assert "was not read: the page was unavailable" in content
|
||||||
|
assert "socket detail" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_url_prefetch_exception_logs_only_stable_diagnostic(
|
||||||
|
monkeypatch, caplog
|
||||||
|
):
|
||||||
|
def fail(url):
|
||||||
|
raise RuntimeError("transport detail LEAK_EXCEPTION_MARKER")
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_processor, "fetch_webpage_content", fail)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger=chat_processor.logger.name):
|
||||||
|
_processor().build_context_preface(
|
||||||
|
message=(
|
||||||
|
"Review https://example.test/report?token=LEAK_URL_MARKER"
|
||||||
|
),
|
||||||
|
session=SimpleNamespace(endpoint_url="", model="", headers={}),
|
||||||
|
use_web=False,
|
||||||
|
use_rag=False,
|
||||||
|
use_memory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Automatic URL fetch failed while building context" in caplog.text
|
||||||
|
assert "LEAK_URL_MARKER" not in caplog.text
|
||||||
|
assert "LEAK_EXCEPTION_MARKER" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_url_prefetch_keeps_existing_content_shape(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_processor,
|
||||||
|
"fetch_webpage_content",
|
||||||
|
lambda url: {"success": True, "content": "page body"},
|
||||||
|
)
|
||||||
|
|
||||||
|
preface, _, _ = _processor().build_context_preface(
|
||||||
|
message="Read https://example.test/page",
|
||||||
|
session=SimpleNamespace(endpoint_url="", model="", headers={}),
|
||||||
|
use_web=False,
|
||||||
|
use_rag=False,
|
||||||
|
use_memory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
page = next(
|
||||||
|
message
|
||||||
|
for message in preface
|
||||||
|
if (message.get("metadata") or {}).get("source")
|
||||||
|
== "web page: https://example.test/page"
|
||||||
|
)
|
||||||
|
assert page["metadata"]["trusted"] is False
|
||||||
|
assert "page body" in page["content"]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_renders_ask_user_in_the_originating_pane():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "renderAskUserCard" in stream
|
||||||
|
assert "} else if (json.type === 'ask_user') {" in stream
|
||||||
|
assert "root: hist" in stream
|
||||||
|
assert "state._paneSessionIds[paneIdx] === sessionId" in stream
|
||||||
|
assert "streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)" in stream
|
||||||
|
assert "handleCompareSubmit" not in stream
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_submits_approval_only_to_the_pane_session():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "fd.append('tool_approval_id', opts.toolApproval.approval_id || '');" in stream
|
||||||
|
assert "fd.append('tool_approval_decision', opts.toolApproval.decision || '');" in stream
|
||||||
|
assert "const isApproval = submission.kind === 'tool_approval';" in stream
|
||||||
|
assert "const message = isApproval ? ''" in stream
|
||||||
|
assert "if (!isApproval) _appendPaneMessage(hist, 'user', message);" in stream
|
||||||
|
assert "json.type === 'tool_approval_resolved'" in stream
|
||||||
|
assert "json.decision === 'deny' ? 'Denied.'" in stream
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_continuation_does_not_lose_or_replace_pane_ownership():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||||
|
index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "if (state._abortControllers[paneIdx] === ac)" in stream
|
||||||
|
assert "if (activeController === originController)" in stream
|
||||||
|
assert "if (activeController) return;" in stream
|
||||||
|
assert "registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });" in index
|
||||||
|
assert "const compareStillStreaming = state._abortControllers.some(Boolean);" in index
|
||||||
|
assert "_setSendBtn(compareStillStreaming ? 'stop' : 'send');" in index
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_restores_the_card_instead_of_dropping_a_timed_out_choice():
|
||||||
|
"""The card is removed the moment a choice is accepted.
|
||||||
|
|
||||||
|
If the originating stream still owns the pane when the resume deadline
|
||||||
|
passes, the decision has nowhere to go — so the card has to come back
|
||||||
|
rather than the click vanishing silently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "function _restorePaneAskUserCard(" in stream
|
||||||
|
assert "_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);" in stream
|
||||||
|
assert "submission.payload || {}" in stream
|
||||||
|
|
||||||
|
start = stream.index("function _resumePaneChoiceWhenIdle(")
|
||||||
|
end = stream.index("function _renderPaneAskUserCard(", start)
|
||||||
|
resume = stream[start:end]
|
||||||
|
# The deadline must not fall through to a bare return any more.
|
||||||
|
assert "if (Date.now() - startedAt < 10000) setTimeout(resume, 25);" not in resume
|
||||||
@@ -16,6 +16,7 @@ from routes.cookbook_helpers import (
|
|||||||
_llama_cpp_rebuild_cmd,
|
_llama_cpp_rebuild_cmd,
|
||||||
_append_vllm_linux_preflight_lines,
|
_append_vllm_linux_preflight_lines,
|
||||||
_local_tooling_path_export,
|
_local_tooling_path_export,
|
||||||
|
_local_windows_bash_env_prefix,
|
||||||
_pip_install_attempt,
|
_pip_install_attempt,
|
||||||
_pip_install_fallback_chain,
|
_pip_install_fallback_chain,
|
||||||
_ollama_bind_from_cmd,
|
_ollama_bind_from_cmd,
|
||||||
@@ -107,6 +108,70 @@ def test_safe_env_prefix_accepts_powershell_activation_path():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("prefix", "expected"),
|
||||||
|
[
|
||||||
|
("& 'C:\\Users\\me\\venv\\Scripts\\Activate.ps1'", "source /c/Users/me/venv/Scripts/activate"),
|
||||||
|
(r"& C:\Users\me\venv\Scripts\Activate.ps1", "source /c/Users/me/venv/Scripts/activate"),
|
||||||
|
(
|
||||||
|
r"& C:\Users\me\My Envs\venv\Scripts\Activate.ps1",
|
||||||
|
"source '/c/Users/me/My Envs/venv/Scripts/activate'",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"& 'C:\\Users\\me\\My Envs\\venv\\Scripts\\Activate.ps1'",
|
||||||
|
"source '/c/Users/me/My Envs/venv/Scripts/activate'",
|
||||||
|
),
|
||||||
|
(r"& D:/Envs/venv/Scripts/Activate.ps1", "source /d/Envs/venv/Scripts/activate"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_local_windows_bash_env_prefix_converts_powershell_venv_activation(prefix, expected):
|
||||||
|
converted = _local_windows_bash_env_prefix(prefix)
|
||||||
|
|
||||||
|
assert converted == expected
|
||||||
|
assert _safe_env_prefix(converted).startswith('[ -f "')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"prefix",
|
||||||
|
[
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
"source /home/me/venv/bin/activate",
|
||||||
|
"conda activate qwen35",
|
||||||
|
'eval "$(conda shell.bash hook)" && conda activate qwen35',
|
||||||
|
r"& \\server\share\venv\Scripts\Activate.ps1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_local_windows_bash_env_prefix_leaves_other_prefixes_unchanged(prefix):
|
||||||
|
assert _local_windows_bash_env_prefix(prefix) == prefix
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_windows_bash_env_prefix_handles_long_whitespace_input():
|
||||||
|
prefix = "\t" * 100_000
|
||||||
|
|
||||||
|
assert _local_windows_bash_env_prefix(prefix) == prefix
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"relative_path",
|
||||||
|
["static/js/cookbookRunning.js", "static/js/cookbookDownload.js"],
|
||||||
|
)
|
||||||
|
def test_primary_windows_venv_emitters_quote_activation_path(relative_path):
|
||||||
|
source = (Path(__file__).resolve().parents[1] / relative_path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "'& ' + _psQuote(" in source
|
||||||
|
assert "_psQuote = shared._psQuote;" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_venv_conversion_stays_scoped_to_local_git_bash_runners():
|
||||||
|
source = (Path(__file__).resolve().parents[1] / "routes/cookbook_routes.py").read_text(encoding="utf-8")
|
||||||
|
guarded_conversion = (
|
||||||
|
"_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert source.count(guarded_conversion) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_validate_local_dir_accepts_external_drive_paths_with_spaces():
|
def test_validate_local_dir_accepts_external_drive_paths_with_spaces():
|
||||||
path = "/Volumes/T7 2TB/AI Models/llamacpp"
|
path = "/Volumes/T7 2TB/AI Models/llamacpp"
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ class _FakeRag:
|
|||||||
|
|
||||||
def search(self, query, k=5):
|
def search(self, query, k=5):
|
||||||
return [
|
return [
|
||||||
{"text": "alpha", "source": "a.txt", "score": 0.9},
|
{
|
||||||
|
"document": "alpha",
|
||||||
|
"metadata": {"source": "a.txt"},
|
||||||
|
"similarity": 0.9,
|
||||||
|
},
|
||||||
"corrupt-row",
|
"corrupt-row",
|
||||||
None,
|
None,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def index_personal_documents(self, directory):
|
||||||
|
return {"indexed_count": 7, "failed_count": 2, "errors": ["bad.pdf"]}
|
||||||
|
|
||||||
|
|
||||||
def test_query_skips_non_dict_rag_rows():
|
def test_query_skips_non_dict_rag_rows():
|
||||||
# Bypass __init__ (it builds a real RAGManager / Chroma client) and inject
|
# Bypass __init__ (it builds a real RAGManager / Chroma client) and inject
|
||||||
@@ -24,3 +31,65 @@ def test_query_skips_non_dict_rag_rows():
|
|||||||
# old code called r.get(...) on the str/None rows and raised AttributeError.
|
# old code called r.get(...) on the str/None rows and raised AttributeError.
|
||||||
assert [c.text for c in out] == ["alpha"]
|
assert [c.text for c in out] == ["alpha"]
|
||||||
assert out[0].source == "a.txt"
|
assert out[0].source == "a.txt"
|
||||||
|
assert out[0].score == 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_maps_live_vectorrag_result_shape():
|
||||||
|
svc = DocsService.__new__(DocsService)
|
||||||
|
svc.rag = _FakeRag()
|
||||||
|
|
||||||
|
out = asyncio.run(svc.index("/documents"))
|
||||||
|
|
||||||
|
assert out.indexed == 7
|
||||||
|
assert out.failed == 2
|
||||||
|
assert out.errors == ["bad.pdf"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_normalizes_null_canonical_fields_and_malformed_metadata():
|
||||||
|
class _MalformedRag:
|
||||||
|
def search(self, query, k=5):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"document": None,
|
||||||
|
"text": "legacy text",
|
||||||
|
"source": None,
|
||||||
|
"similarity": None,
|
||||||
|
"score": 0.4,
|
||||||
|
"metadata": "not-a-dict",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"document": "canonical zero",
|
||||||
|
"similarity": 0.0,
|
||||||
|
"metadata": {"source": "nested.txt"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"document": None,
|
||||||
|
"text": None,
|
||||||
|
"content": "content fallback",
|
||||||
|
"similarity": 0.2,
|
||||||
|
"metadata": ["unexpected"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
svc = DocsService.__new__(DocsService)
|
||||||
|
svc.rag = _MalformedRag()
|
||||||
|
|
||||||
|
out = asyncio.run(svc.query("query"))
|
||||||
|
|
||||||
|
assert [chunk.text for chunk in out] == [
|
||||||
|
"legacy text",
|
||||||
|
"canonical zero",
|
||||||
|
"content fallback",
|
||||||
|
]
|
||||||
|
|
||||||
|
assert out[0].source == "unknown"
|
||||||
|
assert out[0].score == 0.4
|
||||||
|
assert out[0].metadata == {}
|
||||||
|
|
||||||
|
assert out[1].source == "nested.txt"
|
||||||
|
assert out[1].score == 0.0
|
||||||
|
assert out[1].metadata == {"source": "nested.txt"}
|
||||||
|
|
||||||
|
assert out[2].source == "unknown"
|
||||||
|
assert out[2].score == 0.2
|
||||||
|
assert out[2].metadata == {}
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ async def test_chat_stream_approval_restores_exact_shell_turn_toggle(monkeypatch
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
|
async def test_chat_stream_denial_returns_control_resolution(monkeypatch):
|
||||||
from src.tool_capabilities import capabilities_for_action
|
from src.tool_capabilities import capabilities_for_action
|
||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
@@ -368,11 +368,13 @@ async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
response = await endpoint(request)
|
response = await endpoint(request)
|
||||||
async for _ in response.body_iterator:
|
chunks = [chunk async for chunk in response.body_iterator]
|
||||||
pass
|
|
||||||
|
|
||||||
|
event = json.loads(chunks[0][len("data: "):])
|
||||||
|
assert event == {"type": "tool_approval_resolved", "decision": "deny"}
|
||||||
|
assert chunks[-1] == "data: [DONE]\n\n"
|
||||||
|
assert "agent" not in captured
|
||||||
assert "exact_approval" not in captured
|
assert "exact_approval" not in captured
|
||||||
assert captured["agent_external_untrusted_context_seen"] is True
|
|
||||||
assert chat_routes.tool_approval_store.peek(pending.approval_id) is None
|
assert chat_routes.tool_approval_store.peek(pending.approval_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def test_no_hardcoded_loopback_left_in_call_sites():
|
|||||||
"src/tools/_common.py",
|
"src/tools/_common.py",
|
||||||
"src/cookbook_serve_lifecycle.py",
|
"src/cookbook_serve_lifecycle.py",
|
||||||
"src/builtin_actions.py",
|
"src/builtin_actions.py",
|
||||||
"routes/task_routes.py",
|
"routes/task/task_routes.py",
|
||||||
):
|
):
|
||||||
text = (root / rel).read_text(encoding="utf-8")
|
text = (root / rel).read_text(encoding="utf-8")
|
||||||
# Allow it only inside comments; flag any code occurrence.
|
# Allow it only inside comments; flag any code occurrence.
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parent.parent
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
_CHECKER = _REPO / ".github" / "scripts" / "check-issue-description.js"
|
_CHECKER = _REPO / ".github" / "scripts" / "check-issue-description.js"
|
||||||
|
_BUG_TEMPLATE = _REPO / ".github" / "ISSUE_TEMPLATE" / "bug_report.yml"
|
||||||
_WORKFLOW = _REPO / ".github" / "workflows" / "issue-description-check.yml"
|
_WORKFLOW = _REPO / ".github" / "workflows" / "issue-description-check.yml"
|
||||||
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
|
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
|
||||||
|
|
||||||
@@ -66,11 +68,114 @@ checkIssueDescription({ github, context, core })
|
|||||||
return json.loads(proc.stdout)
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_bug_issue(revision):
|
||||||
|
body = f"""# Odysseus Revision
|
||||||
|
{revision}
|
||||||
|
|
||||||
|
# Install Method
|
||||||
|
Docker
|
||||||
|
|
||||||
|
# Operating System
|
||||||
|
Linux
|
||||||
|
|
||||||
|
# Steps to Reproduce
|
||||||
|
1. Start Odysseus.
|
||||||
|
|
||||||
|
# Expected Behaviour
|
||||||
|
The application starts normally.
|
||||||
|
|
||||||
|
# Actual Behaviour
|
||||||
|
The application exits unexpectedly.
|
||||||
|
"""
|
||||||
|
harness = r"""
|
||||||
|
const checkIssueDescription = require(process.argv[1]);
|
||||||
|
const body = process.argv[2];
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
const github = {
|
||||||
|
rest: {
|
||||||
|
issues: {
|
||||||
|
removeLabel: async (params) => calls.push({ method: 'removeLabel', params }),
|
||||||
|
getLabel: async () => ({}),
|
||||||
|
addLabels: async (params) => calls.push({ method: 'addLabels', params }),
|
||||||
|
listComments: async () => ({ data: [] }),
|
||||||
|
createComment: async (params) => calls.push({ method: 'createComment', params }),
|
||||||
|
updateComment: async (params) => calls.push({ method: 'updateComment', params }),
|
||||||
|
deleteComment: async (params) => calls.push({ method: 'deleteComment', params }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
payload: {
|
||||||
|
action: 'opened',
|
||||||
|
issue: { number: 42, state: 'open', body, labels: [{ name: 'bug' }] },
|
||||||
|
},
|
||||||
|
repo: { owner: 'odysseus-dev', repo: 'odysseus' },
|
||||||
|
};
|
||||||
|
const core = {
|
||||||
|
warning: (message) => calls.push({ method: 'warning', message }),
|
||||||
|
setFailed: (message) => calls.push({ method: 'setFailed', message }),
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIssueDescription({ github, context, core })
|
||||||
|
.then(() => process.stdout.write(JSON.stringify(calls)))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "-e", harness, str(_CHECKER), body],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_handles_issue_closures():
|
def test_workflow_handles_issue_closures():
|
||||||
workflow = _WORKFLOW.read_text()
|
workflow = _WORKFLOW.read_text()
|
||||||
assert "types: [opened, edited, reopened, closed]" in workflow
|
assert "types: [opened, edited, reopened, closed]" in workflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_bug_template_requires_exact_revision():
|
||||||
|
template = yaml.safe_load(_BUG_TEMPLATE.read_text())
|
||||||
|
revision = next(item for item in template["body"] if item.get("id") == "revision")
|
||||||
|
assert revision["type"] == "input"
|
||||||
|
assert revision["attributes"]["label"] == "Odysseus Revision"
|
||||||
|
assert "git show -s --abbrev=12 --format='%h (%cs)' HEAD" in revision["attributes"]["description"]
|
||||||
|
assert revision["attributes"]["placeholder"] == "1fef4929cf1d (2026-08-11)"
|
||||||
|
assert revision["validations"]["required"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_bug_checker_accepts_exact_revision():
|
||||||
|
calls = _run_bug_issue("1fef4929cf1d (2026-08-11)")
|
||||||
|
assert not any(call["method"] in {"createComment", "setFailed"} for call in calls)
|
||||||
|
assert any(
|
||||||
|
call["method"] == "addLabels" and call["params"]["labels"] == ["ready for review"]
|
||||||
|
for call in calls
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"revision",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"1fef492",
|
||||||
|
"1fef4929cf1d",
|
||||||
|
"1fef4929cf1d (11 August 2026)",
|
||||||
|
"1fef4929cf1d (2026-08-11) extra",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bug_checker_rejects_missing_or_malformed_revision(revision):
|
||||||
|
calls = _run_bug_issue(revision)
|
||||||
|
comment = next(call["params"]["body"] for call in calls if call["method"] == "createComment")
|
||||||
|
assert "**Odysseus Revision**" in comment
|
||||||
|
assert any(call["method"] == "setFailed" for call in calls)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("action", ["closed", "edited"])
|
@pytest.mark.parametrize("action", ["closed", "edited"])
|
||||||
def test_closed_issue_only_drops_ready_for_review(action):
|
def test_closed_issue_only_drops_ready_for_review(action):
|
||||||
assert _run_closed_issue(action) == [
|
assert _run_closed_issue(action) == [
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from packaging.requirements import Requirement
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_requirement(name):
|
||||||
|
lines = (ROOT / "requirements-optional.txt").read_text(encoding="utf-8").splitlines()
|
||||||
|
return Requirement(next(line for line in lines if line.startswith(name)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_kokoro_optional_dependency_pins_the_verified_release():
|
||||||
|
kokoro = _optional_requirement("kokoro")
|
||||||
|
|
||||||
|
assert str(kokoro.specifier) == "==0.9.4"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("python_version", "selected"),
|
||||||
|
(("3.11", True), ("3.12", True), ("3.13", False), ("3.14", False)),
|
||||||
|
)
|
||||||
|
def test_kokoro_feature_markers_match_supported_python_range(python_version, selected):
|
||||||
|
for name in ("kokoro", "soundfile"):
|
||||||
|
requirement = _optional_requirement(name)
|
||||||
|
assert requirement.marker is not None
|
||||||
|
assert requirement.marker.evaluate({"python_version": python_version}) is selected
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_documents_container_constraint_and_install_command():
|
||||||
|
setup = (ROOT / "docs" / "setup.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "pip install -r requirements-optional.txt" in setup
|
||||||
|
assert "default Docker image currently uses Python 3.14" in setup
|
||||||
|
assert "Python `>=3.10,<3.13`" in setup
|
||||||
|
assert "native Python 3.11 or 3.12" in setup
|
||||||
|
assert "skipped on Python 3.13+" in setup
|
||||||
|
assert "torch build has CUDA" in setup
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
"""KaTeX and Mermaid must be vendored and fetched only on first real use.
|
||||||
|
|
||||||
|
They used to load from cdn.jsdelivr.net in every <head>, costing ~985 KB on the
|
||||||
|
wire per page load, breaking offline installs and announcing each session to a
|
||||||
|
third party. These tests pin the replacement contract: one fetch per library,
|
||||||
|
never before a formula or a ```mermaid fence actually shows up, and math still
|
||||||
|
renders once the library lands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
MERMAID_SRC = "/static/lib/mermaid.min.js"
|
||||||
|
KATEX_SRC = "/static/lib/katex/katex.min.js"
|
||||||
|
KATEX_CSS = "/static/lib/katex/katex.min.css"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def node_available():
|
||||||
|
if not _HAS_NODE:
|
||||||
|
pytest.skip("node binary not on PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def _katex_fonts_block(sw_source: str) -> str:
|
||||||
|
"""The literal body of sw.js's KATEX_FONTS array."""
|
||||||
|
match = re.search(r"const KATEX_FONTS = \[(.*?)\]", sw_source, re.S)
|
||||||
|
assert match, "sw.js no longer defines a KATEX_FONTS array"
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
# A DOM stub small enough to reason about: it records every <script>/<link> the
|
||||||
|
# module injects and lets the test decide when each one "loads", which is the
|
||||||
|
# only way to observe that a second call reuses the first fetch.
|
||||||
|
_HARNESS = r"""
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
// The vendored KaTeX build itself, not a stand-in. A fake renderer that echoes
|
||||||
|
// its input cannot tell "a < b" from "a < b" — real KaTeX reads the "&" as
|
||||||
|
// an alignment marker and returns a .katex-error span, which is the whole
|
||||||
|
// point of the entity tests below. renderToString needs no DOM, so a bare vm
|
||||||
|
// context is enough and keeps the library off the harness globals until a test
|
||||||
|
// installs it deliberately.
|
||||||
|
function loadRealKatex() {
|
||||||
|
const context = { console };
|
||||||
|
context.window = context;
|
||||||
|
context.self = context;
|
||||||
|
context.globalThis = context;
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(fs.readFileSync('./static/lib/katex/katex.min.js', 'utf8'), context);
|
||||||
|
if (!context.katex) throw new Error('vendored katex.min.js did not define a katex global');
|
||||||
|
return context.katex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const injected = { scripts: [], links: [] };
|
||||||
|
|
||||||
|
function makeEl(tag) {
|
||||||
|
return {
|
||||||
|
tagName: String(tag).toUpperCase(),
|
||||||
|
_listeners: {},
|
||||||
|
classList: { remove() {} },
|
||||||
|
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); },
|
||||||
|
fire(type) { (this._listeners[type] || []).forEach((fn) => fn()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTemplate() {
|
||||||
|
return {
|
||||||
|
_html: '',
|
||||||
|
content: { querySelectorAll() { return []; } },
|
||||||
|
set innerHTML(value) { this._html = value; },
|
||||||
|
get innerHTML() { return this._html; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.window = { location: { origin: 'http://localhost' }, katex: null, mermaid: null };
|
||||||
|
globalThis.document = {
|
||||||
|
readyState: 'complete',
|
||||||
|
addEventListener() {},
|
||||||
|
head: {
|
||||||
|
appendChild(el) {
|
||||||
|
if (el.tagName === 'SCRIPT') injected.scripts.push(el);
|
||||||
|
else if (el.tagName === 'LINK') injected.links.push(el);
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createElement(tag) {
|
||||||
|
if (tag === 'template') return makeTemplate();
|
||||||
|
return makeEl(tag);
|
||||||
|
},
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
};
|
||||||
|
globalThis.MutationObserver = class { observe() {} };
|
||||||
|
|
||||||
|
let source = fs.readFileSync('./static/js/markdown.js', 'utf8');
|
||||||
|
source = source.replace(/import uiModule from ['"]\.\/ui\.js['"];/, '');
|
||||||
|
source = source.replace(
|
||||||
|
/import \{ splitTableRow \} from ['"]\.\/markdown\/tableRow\.js['"];/,
|
||||||
|
`function splitTableRow(row) {
|
||||||
|
return (row || '').replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim());
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
const emojiSource = fs.readFileSync('./static/js/emojiShortcodes.js', 'utf8')
|
||||||
|
.replace(/^export default .*$/m, '')
|
||||||
|
.replace(/export const /g, 'const ')
|
||||||
|
.replace(/export function /g, 'function ');
|
||||||
|
source = source.replace(
|
||||||
|
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from ['"]\.\/emojiShortcodes\.js['"];/,
|
||||||
|
() => emojiSource
|
||||||
|
);
|
||||||
|
source = source.replace(
|
||||||
|
/var escapeHtml = uiModule\.esc;/,
|
||||||
|
`var escapeHtml = (value) => String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');`
|
||||||
|
);
|
||||||
|
|
||||||
|
const moduleUrl = 'data:text/javascript;base64,' + Buffer.from(source).toString('base64');
|
||||||
|
const mod = await import(moduleUrl);
|
||||||
|
|
||||||
|
// A container whose querySelectorAll answers from a fixed element list, so a
|
||||||
|
// test can hand the renderer exactly the nodes it wants it to see.
|
||||||
|
function makeContainer(elements) {
|
||||||
|
return {
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return (elements[selector] || []).slice();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = (value) => console.log(JSON.stringify(value));
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _run_node(body: str, timeout: int = 20):
|
||||||
|
script = _HARNESS + textwrap.dedent(body)
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "--input-type=module", "-e", script],
|
||||||
|
cwd=_REPO,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"node failed:\nSTDERR:\n{result.stderr}\nSTDOUT:\n{result.stdout}")
|
||||||
|
return json.loads(result.stdout.splitlines()[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_mermaid_shares_one_load_between_concurrent_callers(node_available):
|
||||||
|
"""Two callers before the library lands must not trigger two fetches."""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const p1 = mod.ensureMermaid();
|
||||||
|
const p2 = mod.ensureMermaid();
|
||||||
|
const samePromise = p1 === p2;
|
||||||
|
const srcs = injected.scripts.map((s) => s.src);
|
||||||
|
|
||||||
|
let initializeCalls = 0;
|
||||||
|
globalThis.window.mermaid = {
|
||||||
|
initialize() { initializeCalls++; },
|
||||||
|
run() {},
|
||||||
|
};
|
||||||
|
injected.scripts[0].fire('load');
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([p1, p2]);
|
||||||
|
emit({
|
||||||
|
samePromise,
|
||||||
|
scriptCount: injected.scripts.length,
|
||||||
|
srcs,
|
||||||
|
sameLibrary: a === b && a === globalThis.window.mermaid,
|
||||||
|
initializeCalls,
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert out["samePromise"] is True
|
||||||
|
assert out["scriptCount"] == 1
|
||||||
|
assert out["srcs"] == [MERMAID_SRC]
|
||||||
|
assert out["sameLibrary"] is True
|
||||||
|
assert out["initializeCalls"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_mermaid_retries_after_a_failed_load(node_available):
|
||||||
|
"""A blocked first fetch must not poison every later diagram."""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const first = mod.ensureMermaid();
|
||||||
|
injected.scripts[0].fire('error');
|
||||||
|
let firstError = null;
|
||||||
|
try { await first; } catch (e) { firstError = e.message; }
|
||||||
|
|
||||||
|
const second = mod.ensureMermaid();
|
||||||
|
const retried = injected.scripts.length === 2;
|
||||||
|
globalThis.window.mermaid = { initialize() {}, run() {} };
|
||||||
|
injected.scripts[1].fire('load');
|
||||||
|
await second;
|
||||||
|
|
||||||
|
emit({ firstError, retried, differentPromise: first !== second });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert MERMAID_SRC in out["firstError"]
|
||||||
|
assert out["retried"] is True
|
||||||
|
assert out["differentPromise"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_mermaid_does_not_fetch_when_no_diagram_is_present(node_available):
|
||||||
|
"""The whole point of the lazy load: no fence, no 3.5 MB download."""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const container = makeContainer({});
|
||||||
|
await mod.renderMermaid(container);
|
||||||
|
emit({ scriptCount: injected.scripts.length });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert out["scriptCount"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_mermaid_fetches_once_a_diagram_is_present(node_available):
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const node = makeEl('pre');
|
||||||
|
node.isConnected = true;
|
||||||
|
const container = makeContainer({ 'pre.mermaid:not([data-processed])': [node] });
|
||||||
|
|
||||||
|
const pending = mod.renderMermaid(container);
|
||||||
|
const srcs = injected.scripts.map((s) => s.src);
|
||||||
|
|
||||||
|
let ranWith = null;
|
||||||
|
globalThis.window.mermaid = {
|
||||||
|
initialize() {},
|
||||||
|
run(opts) { ranWith = opts.nodes.length; },
|
||||||
|
};
|
||||||
|
injected.scripts[0].fire('load');
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
emit({ srcs, ranWith });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert out["srcs"] == [MERMAID_SRC]
|
||||||
|
assert out["ranWith"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_katex_loads_script_and_stylesheet_once(node_available):
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const p1 = mod.ensureKatex();
|
||||||
|
const p2 = mod.ensureKatex();
|
||||||
|
const samePromise = p1 === p2;
|
||||||
|
const scriptSrcs = injected.scripts.map((s) => s.src);
|
||||||
|
const linkHrefs = injected.links.map((l) => l.href);
|
||||||
|
|
||||||
|
globalThis.window.katex = { renderToString: (src) => src };
|
||||||
|
injected.scripts[0].fire('load');
|
||||||
|
injected.links[0].fire('load');
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
emit({ samePromise, scriptSrcs, linkHrefs });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert out["samePromise"] is True
|
||||||
|
assert out["scriptSrcs"] == [KATEX_SRC]
|
||||||
|
assert out["linkHrefs"] == [KATEX_CSS]
|
||||||
|
|
||||||
|
|
||||||
|
def test_md_to_html_defers_math_when_katex_is_not_loaded_yet(node_available):
|
||||||
|
"""Without KaTeX the source is banked verbatim, not dropped or mangled."""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const html = mod.mdToHtml('Inline $x^2 + y_1$ and\\n\\n$$\\\\frac{a}{b}$$\\n');
|
||||||
|
emit({ html, scriptCount: injected.scripts.length });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
html = out["html"]
|
||||||
|
assert 'class="ody-math-pending" data-display="false"' in html
|
||||||
|
assert 'class="ody-math-pending" data-display="true"' in html
|
||||||
|
# The raw source survives the escaping passes — `y_1` must not become <em>.
|
||||||
|
assert "x^2 + y_1" in html
|
||||||
|
assert "<em>" not in html
|
||||||
|
# Nothing is fetched during the synchronous render itself.
|
||||||
|
assert out["scriptCount"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_deferred_math_schedules_a_katex_load(node_available):
|
||||||
|
"""Deferring is only safe if the follow-up actually fires.
|
||||||
|
|
||||||
|
An earlier version scheduled this on requestAnimationFrame, which never runs
|
||||||
|
in a headless browser and is throttled to a stop in a background tab — math
|
||||||
|
then sat as plain source text until the tab was focused.
|
||||||
|
"""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
mod.mdToHtml('Inline $x^2$ here.');
|
||||||
|
const duringRender = injected.scripts.length;
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
emit({ duringRender, scriptSrcs: injected.scripts.map((s) => s.src) });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert out["duringRender"] == 0, "the synchronous render must not block on a fetch"
|
||||||
|
assert out["scriptSrcs"] == [KATEX_SRC]
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_math_reaches_katex_as_characters_not_entities(node_available):
|
||||||
|
""""$a < b$" and "$a < b$" must typeset the same, with no parse error.
|
||||||
|
|
||||||
|
mdToHtml escapes the source before the math pass, so a typed "<" arrives at
|
||||||
|
the delimiters as "<" and a typed "<" arrives as "&lt;". KaTeX
|
||||||
|
has no entity syntax and treats the "&" as an alignment marker, so anything
|
||||||
|
still spelled as an entity comes back as a red .katex-error instead of a
|
||||||
|
formula. Both spellings have to be decoded to the character itself, in one
|
||||||
|
pass — decoding "&" first and "<" after would let the second pass eat
|
||||||
|
what the first produced, which is the double-unescape CodeQL flags.
|
||||||
|
"""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const katex = loadRealKatex();
|
||||||
|
globalThis.window.katex = katex;
|
||||||
|
globalThis.katex = katex;
|
||||||
|
emit({
|
||||||
|
entity: mod.mdToHtml('Math: $a < b$ done.'),
|
||||||
|
typed: mod.mdToHtml('Math: $a < b$ done.'),
|
||||||
|
ampersandEntity: mod.mdToHtml('Math: $x > y$ done.'),
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert "katex-error" not in out["entity"]
|
||||||
|
assert "katex-error" not in out["typed"]
|
||||||
|
assert "katex-error" not in out["ampersandEntity"]
|
||||||
|
# Same formula, same markup, whichever way the author spelled the operator.
|
||||||
|
assert out["entity"] == out["typed"]
|
||||||
|
assert 'class="katex"' in out["entity"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deferred_entity_math_banks_the_decoded_source(node_available):
|
||||||
|
"""The placeholder has to hold the same source the inline path would use.
|
||||||
|
|
||||||
|
renderMath() feeds the span's textContent straight to KaTeX, so an entity
|
||||||
|
left in the bank is a .katex-error that only appears on a cold page — the
|
||||||
|
exact case the lazy load made common.
|
||||||
|
"""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
emit({
|
||||||
|
entity: mod.mdToHtml('Math: $a < b$ done.'),
|
||||||
|
typed: mod.mdToHtml('Math: $a < b$ done.'),
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert 'class="ody-math-pending"' in out["entity"]
|
||||||
|
assert out["entity"] == out["typed"]
|
||||||
|
# Escaped once for transport, so the span's textContent is "a < b".
|
||||||
|
assert "a < b</span>" in out["entity"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detached_container_math_typesets_with_the_real_renderer(node_available):
|
||||||
|
"""The PDF export renders into a container it never attaches to the page.
|
||||||
|
|
||||||
|
mdToHtml defers math to a document-scoped flush, which cannot reach a
|
||||||
|
detached node, so the export has to typeset its own container before
|
||||||
|
handing it to html2pdf. This is that container: pending spans in, real
|
||||||
|
KaTeX markup out, no .katex-error and nothing left pending.
|
||||||
|
"""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const katex = loadRealKatex();
|
||||||
|
const html = mod.mdToHtml('Formula $E = mc^2$ here.');
|
||||||
|
|
||||||
|
const el = makeEl('span');
|
||||||
|
el.textContent = 'E = mc^2';
|
||||||
|
el.getAttribute = (name) => (name === 'data-display' ? 'false' : null);
|
||||||
|
let written = null;
|
||||||
|
Object.defineProperty(el, 'outerHTML', { set(v) { written = v; } });
|
||||||
|
const container = makeContainer({ '.ody-math-pending': [el] });
|
||||||
|
|
||||||
|
const pending = mod.renderMath(container);
|
||||||
|
globalThis.window.katex = katex;
|
||||||
|
injected.scripts[0].fire('load');
|
||||||
|
injected.links[0].fire('load');
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
emit({ html, written });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Cold page: mdToHtml could not typeset, so the export HTML starts pending.
|
||||||
|
assert 'class="ody-math-pending"' in out["html"]
|
||||||
|
# After the export's own render pass it is real KaTeX markup.
|
||||||
|
assert 'class="katex"' in out["written"]
|
||||||
|
assert "katex-error" not in out["written"]
|
||||||
|
assert "ody-math-pending" not in out["written"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_export_typesets_its_container_before_html2pdf():
|
||||||
|
"""Ordering in a call site, so pin the call site. No node needed."""
|
||||||
|
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
|
||||||
|
assert match, "exportAsPdf not found"
|
||||||
|
body = match.group(1)
|
||||||
|
|
||||||
|
render = "await markdownModule.renderMath(container);"
|
||||||
|
assert render in body, "the export never typesets its detached container"
|
||||||
|
assert body.index("container.innerHTML = html;") < body.index(render)
|
||||||
|
assert body.index(render) < body.index("window.html2pdf()")
|
||||||
|
|
||||||
|
|
||||||
|
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
|
||||||
|
"""After the first load mdToHtml goes back to typesetting synchronously."""
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
globalThis.window.katex = {
|
||||||
|
renderToString: (src, opts) => `<span class="katex" data-display="${!!(opts && opts.displayMode)}">${src}</span>`,
|
||||||
|
};
|
||||||
|
globalThis.katex = globalThis.window.katex;
|
||||||
|
const html = mod.mdToHtml('Inline $x^2$ here.');
|
||||||
|
emit({ html });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert '<span class="katex" data-display="false">x^2</span>' in out["html"]
|
||||||
|
assert "ody-math-pending" not in out["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_math_typesets_deferred_placeholders(node_available):
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
const el = makeEl('span');
|
||||||
|
el.textContent = 'x^2';
|
||||||
|
el.getAttribute = (name) => (name === 'data-display' ? 'false' : null);
|
||||||
|
let written = null;
|
||||||
|
Object.defineProperty(el, 'outerHTML', { set(v) { written = v; } });
|
||||||
|
|
||||||
|
const container = makeContainer({ '.ody-math-pending': [el] });
|
||||||
|
const pending = mod.renderMath(container);
|
||||||
|
const scriptSrcs = injected.scripts.map((s) => s.src);
|
||||||
|
|
||||||
|
globalThis.window.katex = {
|
||||||
|
renderToString: (src, opts) => `<span class="katex" data-display="${!!(opts && opts.displayMode)}">${src}</span>`,
|
||||||
|
};
|
||||||
|
injected.scripts[0].fire('load');
|
||||||
|
injected.links[0].fire('load');
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
emit({ scriptSrcs, written });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert out["scriptSrcs"] == [KATEX_SRC]
|
||||||
|
assert out["written"] == '<span class="katex" data-display="false">x^2</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_math_does_not_fetch_without_placeholders(node_available):
|
||||||
|
out = _run_node(
|
||||||
|
"""
|
||||||
|
await mod.renderMath(makeContainer({}));
|
||||||
|
emit({ scriptCount: injected.scripts.length, linkCount: injected.links.length });
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert out["scriptCount"] == 0
|
||||||
|
assert out["linkCount"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_vendored_assets_exist_and_index_html_has_no_cdn_reference():
|
||||||
|
"""Guards the offline/privacy half: no node needed, so it always runs."""
|
||||||
|
for rel in (
|
||||||
|
"static/lib/mermaid.min.js",
|
||||||
|
"static/lib/katex/katex.min.js",
|
||||||
|
"static/lib/katex/katex.min.css",
|
||||||
|
):
|
||||||
|
path = _REPO / rel
|
||||||
|
assert path.is_file(), f"{rel} is not vendored"
|
||||||
|
assert path.stat().st_size > 1024, f"{rel} looks truncated"
|
||||||
|
|
||||||
|
# KaTeX's stylesheet resolves fonts relative to itself; a missing font
|
||||||
|
# degrades silently to fallback glyphs, so resolve every woff2 the vendored
|
||||||
|
# CSS actually asks for. (.woff/.ttf are listed too but never requested by a
|
||||||
|
# browser that supports woff2, which is what static/fonts/ already assumes.)
|
||||||
|
css_dir = _REPO / "static/lib/katex"
|
||||||
|
css = (css_dir / "katex.min.css").read_text(encoding="utf-8")
|
||||||
|
wanted = sorted(set(re.findall(r"url\((fonts/KaTeX_[\w-]+\.woff2)\)", css)))
|
||||||
|
assert len(wanted) == 20, f"expected 20 woff2 references in the CSS, found {len(wanted)}"
|
||||||
|
missing = [ref for ref in wanted if not (css_dir / ref).is_file()]
|
||||||
|
assert missing == [], f"KaTeX stylesheet references fonts that are not vendored: {missing}"
|
||||||
|
|
||||||
|
# Everything the CSS needs must also survive an offline install: the font
|
||||||
|
# names have to be in KATEX_FONTS and that array has to reach PRECACHE.
|
||||||
|
sw = (_REPO / "static/sw.js").read_text(encoding="utf-8")
|
||||||
|
assert "...KATEX_FONTS," in sw, "KATEX_FONTS is defined but never spread into PRECACHE"
|
||||||
|
precached = {
|
||||||
|
f"fonts/KaTeX_{name}.woff2"
|
||||||
|
for name in re.findall(r"'([\w-]+)',", _katex_fonts_block(sw))
|
||||||
|
}
|
||||||
|
assert precached >= set(wanted), f"not precached: {sorted(set(wanted) - precached)}"
|
||||||
|
|
||||||
|
# The shell must fetch no resource from a third party. Scoped to the tags
|
||||||
|
# that actually load something — an <a href> to an external page is fine,
|
||||||
|
# and the comment explaining the move can keep naming the CDN it left.
|
||||||
|
index = (_REPO / "static/index.html").read_text(encoding="utf-8")
|
||||||
|
remote_loads = re.findall(r"<(?:script|link)\b[^>]*\b(?:src|href)=\"https?://[^\"]+", index)
|
||||||
|
assert remote_loads == [], f"index.html loads remote resources: {remote_loads}"
|
||||||
|
|
||||||
|
sw = (_REPO / "static/sw.js").read_text(encoding="utf-8")
|
||||||
|
assert KATEX_SRC in sw
|
||||||
|
assert KATEX_CSS in sw
|
||||||
Executable
+140
@@ -0,0 +1,140 @@
|
|||||||
|
"""Pin the mlx_image_server caller-chosen-model + DNS-rebinding regressions.
|
||||||
|
|
||||||
|
Background: scripts/mlx_image_server.py used to resolve the model per request
|
||||||
|
(``req.model or _args.model``) instead of serving the model the process was
|
||||||
|
launched with. ``_is_hidream`` is a substring test and ``_snapshot_path``
|
||||||
|
accepts either a local directory or a Hugging Face repo id, so a caller could
|
||||||
|
name any directory / repo and the HiDream branch would then run
|
||||||
|
``<model>/scripts/hidream_o1/generate_hidream_o1_mlx.py`` under
|
||||||
|
``sys.executable``. The server has no auth, and the cookbook binds it to
|
||||||
|
``0.0.0.0`` whenever it is serving to a remote host, so that was reachable
|
||||||
|
code execution.
|
||||||
|
|
||||||
|
The fix pins both request paths to ``_args.model``, matching
|
||||||
|
scripts/diffusion_server.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "mlx_image_server.py"
|
||||||
|
|
||||||
|
_BASE_URL = "http://127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module():
|
||||||
|
"""Fresh import of the server module. Unlike diffusion_server it pulls in no
|
||||||
|
heavy runtime (mlx / torch imports all live inside the request handlers), so
|
||||||
|
the real module is imported rather than AST-extracted."""
|
||||||
|
spec = importlib.util.spec_from_file_location("mlx_image_server_under_test", _SCRIPT)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server(monkeypatch):
|
||||||
|
"""Server module launched with a pinned, non-HiDream model."""
|
||||||
|
module = _load_module()
|
||||||
|
module._args = argparse.Namespace(
|
||||||
|
model="mlx-community/pinned-model",
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=8100,
|
||||||
|
steps=0,
|
||||||
|
width=512,
|
||||||
|
height=512,
|
||||||
|
base_model="",
|
||||||
|
lora_style="",
|
||||||
|
lora_paths=[],
|
||||||
|
lora_scales=[],
|
||||||
|
vlm_model="",
|
||||||
|
)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _client(module):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
return TestClient(module.app, base_url=_BASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def _plant_hidream_model_dir(tmp_path: Path) -> tuple[Path, Path]:
|
||||||
|
"""A directory that satisfies _is_hidream() and carries the script the
|
||||||
|
HiDream branch executes. The script writes a marker so the test can tell
|
||||||
|
whether it ran."""
|
||||||
|
model_dir = tmp_path / "hidream-planted"
|
||||||
|
generator = model_dir / "scripts" / "hidream_o1"
|
||||||
|
generator.mkdir(parents=True)
|
||||||
|
marker = model_dir / "executed.txt"
|
||||||
|
(generator / "generate_hidream_o1_mlx.py").write_text(
|
||||||
|
f"open({str(marker)!r}, 'w').write('ran')\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
return model_dir, marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_does_not_run_code_from_a_caller_named_model_dir(server, tmp_path):
|
||||||
|
"""The regression: naming a local directory as the model must not execute
|
||||||
|
the generator script inside it."""
|
||||||
|
model_dir, marker = _plant_hidream_model_dir(tmp_path)
|
||||||
|
|
||||||
|
_client(server).post(
|
||||||
|
"/v1/images/generations",
|
||||||
|
json={"model": str(model_dir), "prompt": "x", "size": "64x64"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not marker.exists(), (
|
||||||
|
"code inside the caller-named model directory ran; the request model "
|
||||||
|
"must not select the generator"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_does_not_fetch_a_caller_named_repo(server, monkeypatch):
|
||||||
|
"""The remote half of the same defect: the caller's string must never reach
|
||||||
|
the Hugging Face downloader."""
|
||||||
|
downloaded = []
|
||||||
|
stub = types.ModuleType("huggingface_hub")
|
||||||
|
stub.snapshot_download = lambda repo: downloaded.append(repo)
|
||||||
|
monkeypatch.setitem(sys.modules, "huggingface_hub", stub)
|
||||||
|
|
||||||
|
_client(server).post(
|
||||||
|
"/v1/images/generations",
|
||||||
|
json={"model": "attacker-account/hidream-anything", "prompt": "x"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "attacker-account/hidream-anything" not in downloaded
|
||||||
|
|
||||||
|
|
||||||
|
def test_edits_does_not_run_code_from_a_caller_named_model_dir(server, tmp_path):
|
||||||
|
"""/v1/images/edits resolved the model the same way and must be pinned too.
|
||||||
|
A "lama" name reaches the inpaint bridge, so the caller's model string is
|
||||||
|
what picks the branch here."""
|
||||||
|
model_dir = tmp_path / "lama-planted"
|
||||||
|
model_dir.mkdir()
|
||||||
|
called = []
|
||||||
|
server._run_inpaint_bridge = lambda *a, **kw: called.append(a)
|
||||||
|
server._run_ddcolor_bridge = lambda *a, **kw: called.append(a)
|
||||||
|
|
||||||
|
resp = _client(server).post(
|
||||||
|
"/v1/images/edits",
|
||||||
|
data={"model": str(model_dir), "prompt": "x"},
|
||||||
|
files={"image": ("i.png", b"not-a-real-png", "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not called, "caller-supplied model selected the edit branch"
|
||||||
|
assert resp.status_code == 422, "pinned non-edit model should be refused"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_hidream_model_is_still_served(server, tmp_path, monkeypatch):
|
||||||
|
"""Behaviour preservation: pinning must not break a server that was actually
|
||||||
|
launched with a HiDream model."""
|
||||||
|
model_dir, marker = _plant_hidream_model_dir(tmp_path)
|
||||||
|
server._args.model = str(model_dir)
|
||||||
|
|
||||||
|
_client(server).post("/v1/images/generations", json={"model": "", "prompt": "x"})
|
||||||
|
|
||||||
|
assert marker.exists(), "the model this server was launched with must still run"
|
||||||
@@ -30,6 +30,15 @@ def test_detect_vendor_uses_endpoint_kind_then_host_and_common_local_ports():
|
|||||||
assert detect_vendor("http://localhost:7000/v1") == VENDOR_GENERIC_OPENAI
|
assert detect_vendor("http://localhost:7000/v1") == VENDOR_GENERIC_OPENAI
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_vendor_requires_a_dns_label_boundary():
|
||||||
|
assert detect_vendor("https://api.openai.com./v1") == VENDOR_OPENAI
|
||||||
|
assert detect_vendor("https://notopenai.com/v1") == VENDOR_GENERIC_OPENAI
|
||||||
|
assert detect_vendor("https://fakeopenrouter.ai/v1") == VENDOR_GENERIC_OPENAI
|
||||||
|
assert detect_vendor("https://notgoogleapis.com/v1") == VENDOR_GENERIC_OPENAI
|
||||||
|
assert detect_vendor("https://evilanthropic.com/v1") == VENDOR_GENERIC_OPENAI
|
||||||
|
assert detect_vendor("https://fakeollama.com/v1") == VENDOR_GENERIC_OPENAI
|
||||||
|
|
||||||
|
|
||||||
def test_generic_openai_reader_keeps_basic_model_payload_unknown():
|
def test_generic_openai_reader_keeps_basic_model_payload_unknown():
|
||||||
records = generic_openai.records_from_payload(
|
records = generic_openai.records_from_payload(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def test_calendar_quick_parse_resolves_with_owner_scope():
|
|||||||
|
|
||||||
|
|
||||||
def test_task_parse_resolves_with_owner_scope():
|
def test_task_parse_resolves_with_owner_scope():
|
||||||
body = _function_source("routes/task_routes.py", "parse_task")
|
body = _function_source("routes/task/task_routes.py", "parse_task")
|
||||||
assert "user = _owner(request)" in body
|
assert "user = _owner(request)" in body
|
||||||
assert 'resolve_endpoint("utility", owner=user or None)' in body
|
assert 'resolve_endpoint("utility", owner=user or None)' in body
|
||||||
assert 'resolve_endpoint("default", owner=user or None)' in body
|
assert 'resolve_endpoint("default", owner=user or None)' in body
|
||||||
|
|||||||
@@ -1107,6 +1107,47 @@ def test_get_api_models_marks_picker_as_pinned_only(monkeypatch):
|
|||||||
assert by_id["anthropic/claude-sonnet-4"]["is_pinned"] is False
|
assert by_id["anthropic/claude-sonnet-4"]["is_pinned"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_fresh_api_models_marks_cached_inventory_as_pinned(monkeypatch):
|
||||||
|
ep = _make_endpoint(
|
||||||
|
base_url="https://api.example.test/v1",
|
||||||
|
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||||
|
pinned_models=None,
|
||||||
|
)
|
||||||
|
db = _PinnedFakeDb([ep])
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
|
||||||
|
|
||||||
|
result = endpoint("ep1", _PinnedFakeRequest(), SimpleNamespace(headers={}))
|
||||||
|
|
||||||
|
assert [row["id"] for row in result] == [
|
||||||
|
"openai/gpt-image-1",
|
||||||
|
"anthropic/claude-sonnet-4",
|
||||||
|
]
|
||||||
|
assert all(row["picker_requires_pinning"] is True for row in result)
|
||||||
|
assert all(row["is_pinned"] is True for row in result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_legacy_api_models_preserves_hidden_selection_as_pinned(monkeypatch):
|
||||||
|
ep = _make_endpoint(
|
||||||
|
base_url="https://api.example.test/v1",
|
||||||
|
cached_models=json.dumps(["m1", "m2", "m3"]),
|
||||||
|
hidden_models=json.dumps(["m2"]),
|
||||||
|
pinned_models=None,
|
||||||
|
)
|
||||||
|
db = _PinnedFakeDb([ep])
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
|
||||||
|
|
||||||
|
result = endpoint("ep1", _PinnedFakeRequest(), SimpleNamespace(headers={}))
|
||||||
|
|
||||||
|
by_id = {row["id"]: row for row in result}
|
||||||
|
assert by_id["m1"]["is_pinned"] is True
|
||||||
|
assert by_id["m2"]["is_pinned"] is False
|
||||||
|
assert by_id["m3"]["is_pinned"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_reprobe_preserves_pinned_models(monkeypatch):
|
def test_reprobe_preserves_pinned_models(monkeypatch):
|
||||||
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
|
ep = _make_endpoint(pinned_models=json.dumps(["deploy-1"]))
|
||||||
db = _PinnedFakeDb([ep])
|
db = _PinnedFakeDb([ep])
|
||||||
@@ -1266,7 +1307,7 @@ def test_list_model_endpoints_returns_key_fingerprint(monkeypatch):
|
|||||||
assert result[1]["api_key_fingerprint"] == ""
|
assert result[1]["api_key_fingerprint"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_list_api_endpoint_reports_inventory_count_when_none_pinned(monkeypatch):
|
def test_list_api_endpoint_defaults_inventory_to_visible_when_none_pinned(monkeypatch):
|
||||||
ep = _make_endpoint(
|
ep = _make_endpoint(
|
||||||
base_url="https://api.example.test/v1",
|
base_url="https://api.example.test/v1",
|
||||||
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||||
@@ -1279,12 +1320,31 @@ def test_list_api_endpoint_reports_inventory_count_when_none_pinned(monkeypatch)
|
|||||||
|
|
||||||
result = endpoint(_PinnedFakeRequest())
|
result = endpoint(_PinnedFakeRequest())
|
||||||
|
|
||||||
assert result[0]["models"] == []
|
assert result[0]["models"] == [
|
||||||
|
"openai/gpt-image-1",
|
||||||
|
"anthropic/claude-sonnet-4",
|
||||||
|
]
|
||||||
|
assert result[0]["pinned_models"] == result[0]["models"]
|
||||||
assert result[0]["model_count"] == 2
|
assert result[0]["model_count"] == 2
|
||||||
assert result[0]["picker_requires_pinning"] is True
|
assert result[0]["picker_requires_pinning"] is True
|
||||||
assert result[0]["status"] == "online"
|
assert result[0]["status"] == "online"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_picker_preserves_explicit_empty_selection():
|
||||||
|
ep = _make_endpoint(
|
||||||
|
base_url="https://api.example.test/v1",
|
||||||
|
cached_models=json.dumps(["openai/gpt-image-1", "anthropic/claude-sonnet-4"]),
|
||||||
|
pinned_models=json.dumps([]),
|
||||||
|
)
|
||||||
|
|
||||||
|
models, pinned = model_routes._picker_models_for_endpoint(
|
||||||
|
ep, ep.base_url, ep.endpoint_kind
|
||||||
|
)
|
||||||
|
|
||||||
|
assert models == []
|
||||||
|
assert pinned == []
|
||||||
|
|
||||||
|
|
||||||
def test_list_api_endpoint_returns_pinned_picker_models(monkeypatch):
|
def test_list_api_endpoint_returns_pinned_picker_models(monkeypatch):
|
||||||
ep = _make_endpoint(
|
ep = _make_endpoint(
|
||||||
base_url="https://api.example.test/v1",
|
base_url="https://api.example.test/v1",
|
||||||
@@ -1767,7 +1827,7 @@ def test_api_models_openrouter_uses_pinned_models_not_hidden(monkeypatch):
|
|||||||
assert result["items"][0]["models"] == ["openai/gpt-image-1"]
|
assert result["items"][0]["models"] == ["openai/gpt-image-1"]
|
||||||
|
|
||||||
|
|
||||||
def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
|
def test_api_models_openrouter_defaults_cached_models_visible(monkeypatch):
|
||||||
row = _route_ep(
|
row = _route_ep(
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"https://openrouter.ai/api/v1",
|
"https://openrouter.ai/api/v1",
|
||||||
@@ -1775,7 +1835,6 @@ def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
|
|||||||
pinned_models=None,
|
pinned_models=None,
|
||||||
api_key="fake-key",
|
api_key="fake-key",
|
||||||
)
|
)
|
||||||
row.hidden_models = json.dumps(["m2"])
|
|
||||||
db = _RouteDb([row])
|
db = _RouteDb([row])
|
||||||
router = model_routes.setup_model_routes(model_discovery=None)
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
@@ -1788,7 +1847,7 @@ def test_api_models_openrouter_derives_legacy_visible_models(monkeypatch):
|
|||||||
result = _route_endpoint(router, "/api/models")(_route_request())
|
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||||
|
|
||||||
assert result["items"][0]["endpoint_name"] == "openrouter"
|
assert result["items"][0]["endpoint_name"] == "openrouter"
|
||||||
assert result["items"][0]["models"] == ["m1", "m3"]
|
assert result["items"][0]["models"] == ["m1", "m2", "m3"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Real-SQLite regressions for session discovery with stale derived counts."""
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
import core.session_manager as session_manager
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manager(db_path, monkeypatch):
|
||||||
|
engine = create_engine(
|
||||||
|
f"sqlite:///{db_path}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=NullPool,
|
||||||
|
)
|
||||||
|
session_manager.DbSession.metadata.create_all(bind=engine)
|
||||||
|
session_local = sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
autoflush=False,
|
||||||
|
autocommit=False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(session_manager, "SessionLocal", session_local)
|
||||||
|
return session_manager.SessionManager(), session_manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_uses_persisted_rows_and_repairs_cached_count(tmp_path, monkeypatch):
|
||||||
|
from core.models import ChatMessage
|
||||||
|
|
||||||
|
manager, session_manager = _make_manager(
|
||||||
|
tmp_path / "session-discovery.db", monkeypatch
|
||||||
|
)
|
||||||
|
for session_id in ("stale-low", "empty", "stale-high"):
|
||||||
|
manager.create_session(
|
||||||
|
session_id=session_id,
|
||||||
|
name=session_id,
|
||||||
|
endpoint_url="http://example.invalid",
|
||||||
|
model="test-model",
|
||||||
|
rag=False,
|
||||||
|
owner="tester",
|
||||||
|
)
|
||||||
|
manager.add_message("stale-low", ChatMessage("user", "persisted message"))
|
||||||
|
|
||||||
|
db = session_manager.SessionLocal()
|
||||||
|
try:
|
||||||
|
db.query(session_manager.DbSession).filter(
|
||||||
|
session_manager.DbSession.id == "stale-low"
|
||||||
|
).update({"message_count": 0})
|
||||||
|
db.query(session_manager.DbSession).filter(
|
||||||
|
session_manager.DbSession.id == "stale-high"
|
||||||
|
).update({"message_count": 7})
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
restarted = session_manager.SessionManager()
|
||||||
|
|
||||||
|
assert set(restarted.sessions) == {"stale-low"}
|
||||||
|
assert restarted.sessions["stale-low"].history == []
|
||||||
|
assert restarted.sessions["stale-low"].message_count == 1
|
||||||
|
|
||||||
|
hydrated = restarted.get_session("stale-low")
|
||||||
|
assert [message.content for message in hydrated.history] == ["persisted message"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Regression test for the task route shim (slice 2p, #4082/#4071).
|
||||||
|
|
||||||
|
The backward-compat shim at ``routes/task_routes.py`` uses ``sys.modules``
|
||||||
|
replacement so the legacy import path and the canonical ``routes.task.*``
|
||||||
|
path resolve to the *same* module object. This is required because multiple
|
||||||
|
tests do ``import routes.task_routes as task_routes`` followed by
|
||||||
|
``monkeypatch.setattr(task_routes, "SessionLocal", ...)`` /
|
||||||
|
``"get_current_user"``, and test_auth_regressions.py reads
|
||||||
|
``task_routes.__file__`` for source introspection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import routes.task_routes as _shim_task # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_and_canonical_task_module_are_same_object():
|
||||||
|
legacy = importlib.import_module("routes.task_routes")
|
||||||
|
canonical = importlib.import_module("routes.task.task_routes")
|
||||||
|
assert legacy is canonical
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_approval_bypasses_polymorphic_send_button_actions():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
|
||||||
|
stream = (root / "static/js/chatStream.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# chat.js still defers the sealed approval through a synthetic button click.
|
||||||
|
assert "if (sendButton) sendButton.click();" in chat
|
||||||
|
|
||||||
|
# The capture listener must intercept only that synthetic click and route it
|
||||||
|
# through the chat form submit path, before app.js can reinterpret an empty
|
||||||
|
# composer as New chat or Record voice.
|
||||||
|
assert "if (event.isTrusted) return;" in stream
|
||||||
|
assert "event.stopImmediatePropagation();" in stream
|
||||||
|
assert "chatForm.requestSubmit()" in stream
|
||||||
|
assert "sendButton.dataset.mode = ''" not in stream
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_user_close_button_uses_one_css_glyph():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
styles = (root / "static/style.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "closeBtn.className = 'modal-close ask-user-close';" in renderer
|
||||||
|
assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" in renderer
|
||||||
|
assert "closeBtn.textContent = '×';" not in renderer
|
||||||
|
assert ".modal-close::before" in styles
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_user_number_shortcuts_reuse_option_click_path():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
start = renderer.index("function _handleAskUserShortcut(event)")
|
||||||
|
end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
|
||||||
|
shortcut = renderer[start:end]
|
||||||
|
|
||||||
|
assert "if (!/^[1-3]$/.test(event.key)) return;" in shortcut
|
||||||
|
assert "event.repeat" in shortcut
|
||||||
|
assert "event.ctrlKey" in shortcut
|
||||||
|
assert "event.altKey" in shortcut
|
||||||
|
assert "event.metaKey" in shortcut
|
||||||
|
assert "event.shiftKey" in shortcut
|
||||||
|
assert "input, textarea, select, [contenteditable=\"true\"]" in shortcut
|
||||||
|
assert "card.querySelectorAll('.ask-user-option')[Number(event.key) - 1]" in shortcut
|
||||||
|
assert "event.preventDefault();" in shortcut
|
||||||
|
assert "option.click();" in shortcut
|
||||||
|
|
||||||
|
|
||||||
|
def test_digit_shortcuts_never_answer_a_tool_approval_card():
|
||||||
|
"""A stray digit must not grant a scope the user did not deliberately pick."""
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
start = renderer.index("function _handleAskUserShortcut(event)")
|
||||||
|
end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
|
||||||
|
shortcut = renderer[start:end]
|
||||||
|
|
||||||
|
assert "if (card.dataset.askUserKind === 'tool_approval') return;" in shortcut
|
||||||
|
# The renderer has to label the card for that guard to ever fire.
|
||||||
|
assert (
|
||||||
|
"card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';"
|
||||||
|
in renderer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_user_renderer_accepts_scoped_root_and_submit_callback():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "const chatBox = renderOptions.root || document.getElementById('chat-history');" in renderer
|
||||||
|
assert "const onSubmit = typeof renderOptions.onSubmit === 'function'" in renderer
|
||||||
|
assert "kind: 'answer'" in renderer
|
||||||
|
assert "kind: 'tool_approval'" in renderer
|
||||||
|
assert "if (accepted !== false) card.remove();" in renderer
|
||||||
|
assert "document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }))" in renderer
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_changed_approval_module_is_cache_busted_together():
|
||||||
|
"""A stale module here silently reinterprets the approval click.
|
||||||
|
|
||||||
|
chat.js leaves the composer empty and clicks the polymorphic send button,
|
||||||
|
so a browser that pairs the new chat.js with a cached chatStream.js has no
|
||||||
|
interceptor and lands on the New chat branch instead. The same holds for
|
||||||
|
the compare pane modules, which chatRenderer now shares a keydown listener
|
||||||
|
with.
|
||||||
|
"""
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
version = "20260819approvalcontrol1"
|
||||||
|
index = (root / "static/index.html").read_text(encoding="utf-8")
|
||||||
|
app = (root / "static/app.js").read_text(encoding="utf-8")
|
||||||
|
chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
|
||||||
|
compare_index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
|
||||||
|
compare_stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert f"chatStream.js?v={version}" in index
|
||||||
|
assert f"chatStream.js?v={version}" in chat
|
||||||
|
assert f"compare/index.js?v={version}" in app
|
||||||
|
assert f"stream.js?v={version}" in compare_index
|
||||||
|
# One chatRenderer instance, so the ask_user keydown listener binds once.
|
||||||
|
assert f"chatRenderer.js?v={version}" in compare_stream
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Callers with no resumable chat keep the original one-use approval scope.
|
||||||
|
|
||||||
|
The chat card reuses the wire value ``approve`` for chat-session scope, so any
|
||||||
|
caller that still sends ``approve`` meaning "once" has to say so explicitly or
|
||||||
|
it silently inherits a run-long gate bypass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.tool_approval_scopes import ToolApprovalScope
|
||||||
|
from src.tool_approvals import ToolApprovalStore
|
||||||
|
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||||
|
|
||||||
|
|
||||||
|
def _pending(store: ToolApprovalStore, *, session_id=""):
|
||||||
|
content = "printf exact"
|
||||||
|
return store.create(
|
||||||
|
owner="Alice",
|
||||||
|
session_id=session_id,
|
||||||
|
origin_run_id="run-1",
|
||||||
|
tool_name="bash",
|
||||||
|
content=content,
|
||||||
|
workspace=None,
|
||||||
|
external_untrusted_context_seen=True,
|
||||||
|
capabilities=capabilities_for_action("bash", content),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_action_grant_leaves_the_gate_armed_behind_the_sealed_action():
|
||||||
|
store = ToolApprovalStore()
|
||||||
|
pending = _pending(store)
|
||||||
|
|
||||||
|
grant = store.consume(
|
||||||
|
pending.approval_id,
|
||||||
|
decision="approve",
|
||||||
|
owner="alice",
|
||||||
|
session_id=None,
|
||||||
|
allow_continuation=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert grant is not None
|
||||||
|
assert grant.scope is ToolApprovalScope.SINGLE_ACTION
|
||||||
|
assert grant.allow_remaining_actions is False
|
||||||
|
assert grant.grants_chat_session is False
|
||||||
|
|
||||||
|
resumed = ToolRunSecurityContext(
|
||||||
|
external_untrusted_context_seen=True,
|
||||||
|
approval_gate_bypassed=grant.allow_remaining_actions,
|
||||||
|
)
|
||||||
|
assert resumed.decision_for("bash").allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_callers_still_get_the_continuation_scope_they_asked_for():
|
||||||
|
store = ToolApprovalStore()
|
||||||
|
pending = _pending(store, session_id="session-1")
|
||||||
|
|
||||||
|
grant = store.consume(
|
||||||
|
pending.approval_id,
|
||||||
|
decision="approve_task",
|
||||||
|
owner="alice",
|
||||||
|
session_id="session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert grant is not None
|
||||||
|
assert grant.scope is ToolApprovalScope.TASK
|
||||||
|
assert grant.allow_remaining_actions is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_deny_is_unaffected_by_the_single_action_flag():
|
||||||
|
store = ToolApprovalStore()
|
||||||
|
pending = _pending(store)
|
||||||
|
|
||||||
|
assert store.consume(
|
||||||
|
pending.approval_id,
|
||||||
|
decision="deny",
|
||||||
|
owner="alice",
|
||||||
|
session_id=None,
|
||||||
|
allow_continuation=False,
|
||||||
|
) is None
|
||||||
|
assert store.peek(pending.approval_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_test_approval_route_opts_out_of_continuation():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
skills = (root / "routes/skills_routes.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
approve_call = skills.index("exact_approval = tool_approval_store.consume(")
|
||||||
|
end = skills.index(")", skills.index("allow_continuation", approve_call))
|
||||||
|
assert "allow_continuation=False" in skills[approve_call:end]
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""Task- and chat-scoped approval continuation coverage for issue #6112."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from core.models import ChatMessage, Session
|
||||||
|
from src.tool_approval_scopes import (
|
||||||
|
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||||
|
ToolApprovalScope,
|
||||||
|
)
|
||||||
|
from src.tool_approvals import ExactToolApproval, ToolApprovalStore
|
||||||
|
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||||
|
|
||||||
|
|
||||||
|
def _pending(
|
||||||
|
store: ToolApprovalStore,
|
||||||
|
*,
|
||||||
|
selected_tools=None,
|
||||||
|
continuation_query="inspect the project using memory and skills",
|
||||||
|
):
|
||||||
|
content = "printf exact"
|
||||||
|
return store.create(
|
||||||
|
owner="Alice",
|
||||||
|
session_id="session-1",
|
||||||
|
origin_run_id="run-1",
|
||||||
|
tool_name="bash",
|
||||||
|
content=content,
|
||||||
|
workspace=None,
|
||||||
|
external_untrusted_context_seen=True,
|
||||||
|
selected_tools=selected_tools,
|
||||||
|
continuation_query=continuation_query,
|
||||||
|
capabilities=capabilities_for_action("bash", content),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_offers_task_chat_session_and_deny_without_leaking_private_state():
|
||||||
|
pending = _pending(
|
||||||
|
ToolApprovalStore(),
|
||||||
|
selected_tools=["manage_skills", "bash", "manage_skills"],
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = pending.public_payload()
|
||||||
|
|
||||||
|
assert payload["session_id"] == "session-1"
|
||||||
|
assert [option["value"] for option in payload["options"]] == [
|
||||||
|
"approve_task",
|
||||||
|
"approve",
|
||||||
|
"deny",
|
||||||
|
]
|
||||||
|
assert [option["label"] for option in payload["options"]] == [
|
||||||
|
"Allow for this task",
|
||||||
|
"Allow for this chat session",
|
||||||
|
"Deny",
|
||||||
|
]
|
||||||
|
serialized = json.dumps(payload, sort_keys=True)
|
||||||
|
assert "Allow once" not in serialized
|
||||||
|
assert "selected_tools" not in serialized
|
||||||
|
assert "continuation_query" not in serialized
|
||||||
|
assert "manage_skills" not in serialized
|
||||||
|
assert "inspect the project" not in serialized
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_for_task_bypasses_only_the_resumed_run_gate():
|
||||||
|
store = ToolApprovalStore()
|
||||||
|
pending = _pending(store, selected_tools=["bash", "manage_skills"])
|
||||||
|
grant = store.consume(
|
||||||
|
pending.approval_id,
|
||||||
|
decision="approve_task",
|
||||||
|
owner="alice",
|
||||||
|
session_id="session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert grant is not None
|
||||||
|
assert grant.scope is ToolApprovalScope.TASK
|
||||||
|
assert grant.allow_remaining_actions is True
|
||||||
|
assert grant.grants_chat_session is False
|
||||||
|
assert grant.pending.continuation_query == (
|
||||||
|
"inspect the project using memory and skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
resumed = ToolRunSecurityContext(
|
||||||
|
external_untrusted_context_seen=True,
|
||||||
|
approval_gate_bypassed=grant.allow_remaining_actions,
|
||||||
|
)
|
||||||
|
assert resumed.decision_for("bash").allowed is True
|
||||||
|
|
||||||
|
# A new ordinary user turn constructs a fresh context and asks again.
|
||||||
|
fresh = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||||
|
assert fresh.decision_for("bash").allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_for_chat_session_applies_to_later_turns_in_only_that_chat():
|
||||||
|
store = ToolApprovalStore()
|
||||||
|
pending = _pending(store, selected_tools=["bash", "manage_skills"])
|
||||||
|
grant = store.consume(
|
||||||
|
pending.approval_id,
|
||||||
|
decision="approve",
|
||||||
|
owner="alice",
|
||||||
|
session_id="session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert grant is not None
|
||||||
|
assert grant.scope is ToolApprovalScope.CHAT_SESSION
|
||||||
|
assert grant.allow_remaining_actions is True
|
||||||
|
assert grant.grants_chat_session is True
|
||||||
|
assert grant.pending.selected_tools == ("bash", "manage_skills")
|
||||||
|
assert grant.pending.continuation_query.startswith("inspect the project")
|
||||||
|
|
||||||
|
resolved_card = pending.public_payload()
|
||||||
|
resolved_card["resolved"] = "approve"
|
||||||
|
history = [
|
||||||
|
ChatMessage(
|
||||||
|
"assistant",
|
||||||
|
"approval requested",
|
||||||
|
{"tool_events": [{"ask_user": resolved_card}]},
|
||||||
|
),
|
||||||
|
ChatMessage("user", "continue the work"),
|
||||||
|
]
|
||||||
|
session = Session(
|
||||||
|
id="session-1",
|
||||||
|
name="Chat",
|
||||||
|
endpoint_url="http://example.invalid",
|
||||||
|
model="test",
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = session.get_context_messages()
|
||||||
|
assert messages[-1]["metadata"][CHAT_SESSION_APPROVAL_CONTEXT_MARKER] is True
|
||||||
|
assert history[-1].metadata is None
|
||||||
|
|
||||||
|
future_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||||
|
future_turn.observe_messages(messages)
|
||||||
|
assert future_turn.approval_gate_bypassed is True
|
||||||
|
assert future_turn.decision_for("bash").allowed is True
|
||||||
|
|
||||||
|
# The persisted card is bound to its original chat id, so a fork/copy does
|
||||||
|
# not inherit the grant merely by copying transcript metadata.
|
||||||
|
other_session = Session(
|
||||||
|
id="session-2",
|
||||||
|
name="Fork",
|
||||||
|
endpoint_url="http://example.invalid",
|
||||||
|
model="test",
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
other_messages = other_session.get_context_messages()
|
||||||
|
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
|
||||||
|
other_messages[-1].get("metadata") or {}
|
||||||
|
)
|
||||||
|
other_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
|
||||||
|
other_turn.observe_messages(other_messages)
|
||||||
|
assert other_turn.decision_for("bash").allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deny_executes_nothing_and_grants_no_task_or_chat_scope():
|
||||||
|
store = ToolApprovalStore()
|
||||||
|
pending = _pending(store)
|
||||||
|
|
||||||
|
assert store.consume(
|
||||||
|
pending.approval_id,
|
||||||
|
decision="deny",
|
||||||
|
owner="alice",
|
||||||
|
session_id="session-1",
|
||||||
|
) is None
|
||||||
|
assert store.peek(pending.approval_id) is None
|
||||||
|
|
||||||
|
denied_card = pending.public_payload()
|
||||||
|
denied_card["resolved"] = "deny"
|
||||||
|
session = Session(
|
||||||
|
id="session-1",
|
||||||
|
name="Chat",
|
||||||
|
endpoint_url="http://example.invalid",
|
||||||
|
model="test",
|
||||||
|
history=[
|
||||||
|
ChatMessage(
|
||||||
|
"assistant",
|
||||||
|
"approval requested",
|
||||||
|
{"tool_events": [{"ask_user": denied_card}]},
|
||||||
|
),
|
||||||
|
ChatMessage("user", "another request"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
messages = session.get_context_messages()
|
||||||
|
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
|
||||||
|
messages[-1].get("metadata") or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_continuation_state_is_canonical_bounded_and_digest_bound():
|
||||||
|
selected_tools = ["manage_skills", "bash", "manage_skills", "", 7]
|
||||||
|
selected_tools.extend(f"tool_{index:04d}" for index in range(600))
|
||||||
|
selected_tools.append("x" * 513)
|
||||||
|
pending = _pending(
|
||||||
|
ToolApprovalStore(),
|
||||||
|
selected_tools=selected_tools,
|
||||||
|
continuation_query=" " + ("original request " * 500),
|
||||||
|
)
|
||||||
|
assert pending.selected_tools[:2] == ("bash", "manage_skills")
|
||||||
|
assert len(pending.selected_tools) == 512
|
||||||
|
assert all(len(name) <= 512 for name in pending.selected_tools)
|
||||||
|
assert "x" * 513 not in pending.selected_tools
|
||||||
|
assert pending.continuation_query.startswith("original request")
|
||||||
|
assert len(pending.continuation_query) == 4000
|
||||||
|
|
||||||
|
tampered = replace(
|
||||||
|
pending,
|
||||||
|
selected_tools=("bash", "manage_skills", "send_email"),
|
||||||
|
continuation_query="different request",
|
||||||
|
)
|
||||||
|
grant = ExactToolApproval(tampered)
|
||||||
|
assert grant.matches(
|
||||||
|
owner="alice",
|
||||||
|
session_id="session-1",
|
||||||
|
tool_name="bash",
|
||||||
|
content="printf exact",
|
||||||
|
workspace=None,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_consumed_card_resolution_updates_memory_and_persisted_metadata(monkeypatch):
|
||||||
|
from routes import chat_routes
|
||||||
|
|
||||||
|
ask_user = {
|
||||||
|
"kind": "tool_approval",
|
||||||
|
"approval_id": "approval-1",
|
||||||
|
"session_id": "session-1",
|
||||||
|
}
|
||||||
|
metadata = {
|
||||||
|
"_db_id": "message-1",
|
||||||
|
"tool_events": [{"ask_user": ask_user}],
|
||||||
|
}
|
||||||
|
sess = SimpleNamespace(
|
||||||
|
id="session-1",
|
||||||
|
history=[SimpleNamespace(metadata=metadata)],
|
||||||
|
)
|
||||||
|
db_message = SimpleNamespace(meta_data=None)
|
||||||
|
|
||||||
|
class Column:
|
||||||
|
def __eq__(self, value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
class FakeDBMessage:
|
||||||
|
id = Column()
|
||||||
|
session_id = Column()
|
||||||
|
|
||||||
|
class FakeQuery:
|
||||||
|
def filter(self, *conditions):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return db_message
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
committed = False
|
||||||
|
rolled_back = False
|
||||||
|
closed = False
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
assert model is FakeDBMessage
|
||||||
|
return FakeQuery()
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
self.rolled_back = True
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
db = FakeDB()
|
||||||
|
monkeypatch.setattr(chat_routes, "DBChatMessage", FakeDBMessage)
|
||||||
|
monkeypatch.setattr(chat_routes, "SessionLocal", lambda: db)
|
||||||
|
|
||||||
|
assert chat_routes._mark_tool_approval_resolved(
|
||||||
|
sess,
|
||||||
|
"approval-1",
|
||||||
|
"approve",
|
||||||
|
) is True
|
||||||
|
assert ask_user["resolved"] == "approve"
|
||||||
|
persisted = json.loads(db_message.meta_data)
|
||||||
|
assert persisted["tool_events"][0]["ask_user"]["resolved"] == "approve"
|
||||||
|
assert "_db_id" not in persisted
|
||||||
|
assert db.committed is True
|
||||||
|
assert db.rolled_back is False
|
||||||
|
assert db.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_deny_resolution_stream_is_control_only():
|
||||||
|
from routes.chat_routes import _tool_approval_resolution_stream
|
||||||
|
|
||||||
|
async def collect():
|
||||||
|
return [chunk async for chunk in _tool_approval_resolution_stream("deny")]
|
||||||
|
|
||||||
|
chunks = asyncio.run(collect())
|
||||||
|
assert chunks[-1] == "data: [DONE]\n\n"
|
||||||
|
event = json.loads(chunks[0][len("data: "):])
|
||||||
|
assert event == {"type": "tool_approval_resolved", "decision": "deny"}
|
||||||
|
assert "Denied the" not in "".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_context_agent_frontend_and_cache_bust_wire_the_contract():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
route = (root / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||||
|
helpers = (root / "routes/chat_helpers.py").read_text(encoding="utf-8")
|
||||||
|
agent = (root / "src/agent_loop.py").read_text(encoding="utf-8")
|
||||||
|
frontend = (root / "static/js/chat.js").read_text(encoding="utf-8")
|
||||||
|
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
app = (root / "static/app.js").read_text(encoding="utf-8")
|
||||||
|
index = (root / "static/index.html").read_text(encoding="utf-8")
|
||||||
|
approvals = (root / "src/tool_approvals.py").read_text(encoding="utf-8")
|
||||||
|
capabilities = (root / "src/tool_capabilities.py").read_text(encoding="utf-8")
|
||||||
|
models = (root / "core/models.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'decision not in {"approve", "approve_task", "deny"}' in route
|
||||||
|
assert "set(pending_tool_approval.selected_tools)" in route
|
||||||
|
assert "pending_tool_approval.continuation_query" in route
|
||||||
|
assert "persist_user_message=not tool_approval_continuation" in route
|
||||||
|
assert "_mark_tool_approval_resolved(" in route
|
||||||
|
assert "_tool_approval_resolution_stream(decision)" in route
|
||||||
|
assert "Approved the exact" not in route
|
||||||
|
assert "Denied the" not in route
|
||||||
|
assert "continuation_context_message: str | None = None" in helpers
|
||||||
|
assert "persist_user_message: bool = True" in helpers
|
||||||
|
assert "_without_latest_matching_user_message(" not in helpers
|
||||||
|
assert "selected_tools=approval_selected_tools" in agent
|
||||||
|
assert "continuation_query=_retrieval_query or _last_user" in agent
|
||||||
|
assert "approval_gate_bypassed=bool(" in agent
|
||||||
|
assert "['approve', 'approve_task', 'deny']" in frontend
|
||||||
|
assert "input.value = label" not in frontend
|
||||||
|
assert "const msg = approvalForSend ? '' : el('message').value;" in frontend
|
||||||
|
assert "const skipBubble = _hideUserBubble || !!approvalForSend;" in frontend
|
||||||
|
assert "fd.append('message', approvalForSend ? '' : _finalMsgWithInject);" in frontend
|
||||||
|
assert "json.type === 'tool_approval_resolved'" in frontend
|
||||||
|
assert "if (aq.resolved) return null;" in renderer
|
||||||
|
assert "ev.ask_user && !ev.ask_user.resolved" in renderer
|
||||||
|
assert '"label": "Allow once"' not in approvals
|
||||||
|
assert '"label": "Allow for this task"' in approvals
|
||||||
|
assert '"label": "Allow for this chat session"' in approvals
|
||||||
|
assert "scope_for_decision(normalized_decision)" in approvals
|
||||||
|
assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in capabilities
|
||||||
|
assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in models
|
||||||
|
|
||||||
|
version = "20260819approvalcontrol1"
|
||||||
|
assert f"chat.js?v={version}" in app
|
||||||
|
assert f"chat.js?v={version}" in index
|
||||||
|
assert f"chatRenderer.js?v={version}" in frontend
|
||||||
|
assert f"chatRenderer.js?v={version}" in app
|
||||||
|
assert f"chatRenderer.js?v={version}" in index
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user