Compare commits

..
Author SHA1 Message Date
RaresKeY 96c88c27c8 fix(personal): bound multi-file upload memory 2026-08-15 09:02:55 +00:00
Joeseph Grey 6edd771cc9 Merge branch 'dev' into fix/add-directory-event-loop 2026-08-12 10:39:22 -06:00
StressTestor 938251000b fix(personal): route upload and delete through the index job lock
/api/personal/upload and DELETE /api/personal/file mutated the same
vector and tracking state add/remove/reload serialize on, outside
_index_job_lock and inline on the event loop.

Both now stage async work on the loop, then run the complete transition
(vector writes, disk change, personal_docs_manager update) in one
offloaded critical section under the shared lock, acquired before the
offload so queued requests park on the loop rather than pinning a
threadpool worker.

Adds add-vs-upload and add-vs-file ordering regressions.
2026-08-12 10:17:36 -06:00
StressTestor e222e92153 fix(personal): serialize add/remove/reload on an async job lock
The #5558 fix took the job lock INSIDE the threadpool worker and only on the
add path, so (1) remove_directory and /reload mutated PersonalDocsManager's
unsynchronized list/index concurrently with an in-flight add — the inconsistent
state the PR claimed to prevent — and (2) a queued add blocked on the lock while
holding an AnyIO threadpool token, starving the shared pool.

Move the lock to an asyncio.Lock acquired in the async handler BEFORE offloading,
and route add, remove and reload through it. A waiting request now parks on the
event loop instead of pinning a worker, and all three mutators are serialized so
the 'add/remove are serialized and cannot leave inconsistent state' guarantee
holds. remove and reload also run their blocking work off the event loop. The
lock is per-router so each app binds it to its own loop; single-process scope.

Tests: add-vs-remove and add-vs-reload serialization regressions (async via
ASGITransport, since asyncio.Lock deadlocks starlette TestClient's portal); the
existing add-vs-add test converted to the same driver.
2026-07-27 20:43:04 +00:00
StressTestor b91f48f50a fix(personal): run directory indexing off the event loop (#5558)
POST /api/personal/add_directory called rag.index_personal_documents
inline from an async handler, so the whole indexing job (os.walk, file
reads, per-chunk embedding, Chroma inserts) ran on the event loop and
every other request queued behind it. Indexing a real directory froze
the UI and API for 25+ minutes with no sign of life.

Move the blocking section into the threadpool via run_in_threadpool.
personal_docs_manager.add_directory stays inside it because its
refresh_index() re-extracts text across tracked directories, which is
also blocking work. A module-level lock serializes index jobs so the
threadpool move does not introduce parallel jobs racing
PersonalDocsManager's unsynchronized list mutations and file writes;
they previously serialized on the blocked loop, so one-at-a-time is
behavior parity.
2026-07-27 20:43:04 +00:00
158 changed files with 1935 additions and 24354 deletions
-23
View File
@@ -76,14 +76,6 @@ SEARXNG_INSTANCE=http://localhost:8080
# Change this if another local service already uses 7000 (macOS AirPlay often does).
# APP_PORT=7000
# Optional HTTP address advertised in companion/mobile pairing codes. Set this
# when Docker would otherwise advertise a container address or loopback. Use a
# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local
# name that the phone can reach. HTTPS and public hostnames are not supported
# by the current companion client. Do not include credentials, a path, query,
# or fragment.
# COMPANION_BASE_URL=http://192.168.1.50:7000
# Development-only auth bypass for loopback requests.
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
# LOCALHOST_BYPASS=false
@@ -159,21 +151,6 @@ SEARXNG_INSTANCE=http://localhost:8080
# Local HTTP setups may use the callback URL inferred by the application.
# GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback
# Origin the MCP OAuth callback is sent back to, for remote (Streamable HTTP)
# MCP servers that register it dynamically. Defaults to http://localhost:$APP_PORT,
# which is right only when you reach Odysseus directly on that port. Set it for
# HTTPS, reverse-proxy, hosted, and Docker installs — inside the container the
# app always listens on 7000 and cannot see the host port map, so the default is
# wrong there whenever APP_PORT is not 7000.
#
# Not for Google MCP servers. Those use Desktop App credentials, and Google only
# accepts loopback redirect URIs for that client type, so a public origin here is
# rejected with redirect_uri_mismatch. Leave it unset for a Google-only install:
# the loopback default is what Google wants, and remote users finish through the
# paste-back page, which never has to load the redirect.
# https://developers.google.com/identity/protocols/oauth2/native-app
# OAUTH_REDIRECT_BASE_URL=https://your-domain.com
# ============================================================
# Misc
# ============================================================
-1
View File
@@ -28,7 +28,6 @@ Fixes #
- [ ] This PR targets `dev`
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
- [ ] I did not run the app/runtime validation and stated that gap in **How to Test**. Leave this unchecked when the app-run box above is checked.
## How to Test
+32 -142
View File
@@ -21,11 +21,11 @@ module.exports = async ({ github, context, core }) => {
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
}
const descriptionProblems = [];
const problems = [];
// 1. Summary must be filled in.
if (section('Summary').length < 20) {
descriptionProblems.push('**Summary** is empty or too short — describe what changed and why.');
problems.push('**Summary** is empty or too short — describe what changed and why.');
}
// 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing
@@ -34,18 +34,18 @@ module.exports = async ({ github, context, core }) => {
const linkedSection = section('Linked Issue');
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
if (!linkedSection || !hasIssueRef) {
descriptionProblems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
}
// 3. At least one Type of Change box must be checked.
const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? '';
if (!/- \[x\]/i.test(typeBlock)) {
descriptionProblems.push('**Type of Change** — check at least one box.');
problems.push('**Type of Change** — check at least one box.');
}
// 4. Duplicate-search checklist item must be checked.
if (!/- \[x\] I searched/i.test(body)) {
descriptionProblems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
problems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
}
// 5. How to Test must contain enough real detail for a reviewer to act on.
@@ -53,83 +53,7 @@ module.exports = async ({ github, context, core }) => {
// code block — so we only require non-trivial content, not a specific shape.
const howTo = section('How to Test');
if (howTo.length < 30) {
descriptionProblems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
}
// Classify paths from GitHub's API. This workflow runs in the privileged base
// context, so it must never check out or execute code from the PR branch.
const changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: prNum, per_page: 100,
});
const changedPaths = changedFiles.map(file => file.filename);
function isUiSensitivePath(filename) {
const path = filename.toLowerCase();
return path.startsWith('static/')
|| path.startsWith('templates/')
|| /\.(?:html?|css|svg)$/.test(path);
}
function isDocsOnlyPath(filename) {
const path = filename.toLowerCase();
return /\.(?:md|mdx|rst|adoc|txt)$/.test(path)
|| (path.startsWith('docs/') && !isUiSensitivePath(path));
}
function isRuntimeSensitivePath(filename) {
const path = filename.toLowerCase();
if (isUiSensitivePath(path)) return false;
if (path.startsWith('tests/') || path.startsWith('.github/')) return false;
return /^(?:app\.py|routes\/|services\/|src\/|core\/|mcp_servers\/|scripts\/|docker\/)/.test(path)
|| /^(?:dockerfile|docker-compose.*\.ya?ml|requirements(?:-optional)?\.txt|pyproject\.toml|setup\.py)$/.test(path)
|| /\.(?:py|sh|ps1|bat)$/.test(path);
}
let classification = 'tooling';
if (changedPaths.some(isUiSensitivePath)) {
classification = 'UI-sensitive';
} else if (changedPaths.some(isRuntimeSensitivePath)) {
classification = 'backend/runtime';
} else if (changedPaths.length > 0 && changedPaths.every(isDocsOnlyPath)) {
classification = 'docs-only';
}
const appRan = /- \[x\]\s+I actually ran the app\b/i.test(body);
const appNotRun = /- \[x\]\s+I did not run the app\/runtime validation\b/i.test(body);
// Anchor on the wording, not the template's emphasis: a ticked box the author
// retyped without the surrounding ** renders identically on the PR page, so
// treating it as unchecked is invisible from their side. Matches the two
// attestations above, which already ignore formatting.
const screenshotChecked = /- \[x\]\s+[*_]{0,2}Screenshot or short clip[*_]{0,2}/i.test(body);
const screenshotSection = section('Screenshots / clips');
const hasVisualEvidence = /!\[[^\]]*\]\([^)]+\)|<(?:img|video|source)\b[^>]*(?:src|href)=|https?:\/\/[^\s)]+/i.test(screenshotSection);
const evidenceGaps = [];
let needsRuntimeValidation = false;
let needsVisualEvidence = false;
if (classification === 'backend/runtime' || classification === 'UI-sensitive') {
if (appRan && appNotRun) {
needsRuntimeValidation = true;
evidenceGaps.push('The app-run and explicit not-run boxes are both checked. Select the one state that is true.');
} else if (!appRan) {
needsRuntimeValidation = true;
if (appNotRun) {
evidenceGaps.push('The author explicitly reports that app/runtime validation was not performed.');
} else {
evidenceGaps.push('App/runtime validation is not author-attested. Check the run box only after running it, or check the explicit not-run box and describe the gap.');
}
}
}
if (classification === 'UI-sensitive') {
if (!screenshotChecked) {
needsVisualEvidence = true;
evidenceGaps.push('The screenshot/clip checkbox is not checked for this UI-sensitive change.');
}
if (!hasVisualEvidence) {
needsVisualEvidence = true;
evidenceGaps.push('The Screenshots / clips section does not contain an actual attachment or link.');
}
problems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
}
// ── Comment ──────────────────────────────────────────────────────────────
@@ -138,43 +62,22 @@ module.exports = async ({ github, context, core }) => {
});
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
if (descriptionProblems.length === 0 && evidenceGaps.length === 0) {
if (problems.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
} else {
const commentLines = [MARKER];
if (descriptionProblems.length > 0) {
commentLines.push(
'⚠️ **PR description — action needed**',
'',
'The following required sections are missing or incomplete. Please update the PR description to address them:',
'',
descriptionProblems.map(problem => `- ${problem}`).join('\n'),
);
} else {
commentLines.push(
'⚠️ **PR description is complete; validation evidence is still outstanding**',
'',
`Changed-file classification: **${classification}**.`,
);
}
if (evidenceGaps.length > 0) {
commentLines.push(
'',
'**Author-reported runtime / visual state**',
'',
evidenceGaps.map(gap => `- ${gap}`).join('\n'),
'',
'Checkboxes are author attestations. GitHub Actions results remain the execution evidence for CI; this check does not prove that a local command ran.',
);
}
commentLines.push(
const commentBody = [
MARKER,
'⚠️ **PR description — action needed**',
'',
'The following required sections are missing or incomplete. Please update the PR description to address them:',
'',
problems.map(p => `- ${p}`).join('\n'),
'',
'---',
'_This comment updates automatically when the description or changed files change._',
);
const commentBody = commentLines.join('\n');
'_This comment is deleted automatically once all sections are complete._',
].join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
@@ -194,47 +97,34 @@ module.exports = async ({ github, context, core }) => {
return true;
} catch (e) {
if (e.status === 404) return false;
if (e.status === 403) {
core.warning(`Could not inspect label "${name}" — token lacks label read access; skipping.`);
return false;
}
throw e;
}
}
async function setLabel(name, wanted) {
if (wanted && await labelExists(name)) {
async function swapLabel(num, add, remove) {
if (await labelExists(add)) {
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNum, labels: [name] });
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] });
} catch (e) {
// Fail soft on a token that can't write labels so a label permission
// problem never masks the actual description verdict.
if (e.status !== 403 && e.status !== 404) throw e;
core.warning(`Could not add "${name}" — label is unavailable or the token lacks label write access; skipping.`);
if (e.status !== 403) throw e;
core.warning(`Could not add "${add}" — token lacks label write here; skipping.`);
}
} else if (wanted) {
core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`);
} else {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNum, name });
} catch (e) {
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
}
core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`);
}
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove });
} catch (e) {
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
}
}
const descriptionComplete = descriptionProblems.length === 0;
const evidenceComplete = evidenceGaps.length === 0;
const isDraft = Boolean(context.payload.pull_request.draft);
await setLabel(
'ready for review',
descriptionComplete && evidenceComplete && !isDraft,
);
await setLabel('needs work', !descriptionComplete);
await setLabel('needs runtime validation', needsRuntimeValidation);
await setLabel('needs visual evidence', needsVisualEvidence);
if (!descriptionComplete) {
core.setFailed(`PR description has ${descriptionProblems.length} issue(s) — see bot comment for details.`);
if (problems.length === 0) {
await swapLabel(prNum, 'ready for review', 'needs work');
} else {
await swapLabel(prNum, 'needs work', 'ready for review');
core.setFailed(`PR description has ${problems.length} issue(s) — see bot comment for details.`);
}
};
+3 -9
View File
@@ -5,11 +5,7 @@ on:
# works on fork PRs. Safe here: the checkout pins to the base branch (no fork
# code runs) and the scripts only read context.payload and call the GitHub API.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, synchronize, reopened, ready_for_review, converted_to_draft]
concurrency:
group: pr-description-${{ github.event.pull_request.number }}
cancel-in-progress: true
types: [opened, edited, synchronize, reopened, ready_for_review]
# Default-deny at the workflow level; each job opts into only the scopes it needs.
# Note: modifying a PR's labels/comments needs pull-requests:write even though the
@@ -63,14 +59,12 @@ jobs:
check-mergeable:
name: Flag unmergeable PRs
needs: check-description
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
# Run after description validation failures, but never from an obsolete
# workflow run canceled by a newer PR event.
if: ${{ !cancelled() && github.event.pull_request.user.type != 'Bot' }}
# Skip bots: they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot'
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+1 -6
View File
@@ -59,12 +59,7 @@ Help is welcome. The best entry points are fresh-install testing, provider setup
## Security
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly.
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
Deployment details are in the [setup guide](docs/setup.md#security-notes).
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes).
## Star History
+1 -1
View File
@@ -37,7 +37,7 @@ Non-admin defaults are in `core/auth.py:DEFAULT_PRIVILEGES`. Tool enforcement is
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
- **Reserved usernames:** request sentinels and the Default/Local storage owner cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
- **Reserved usernames:** `internal-tool`, `api`, `demo`, `system` cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
- **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
+9 -33
View File
@@ -67,13 +67,7 @@ from core.constants import (
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
)
from core.database import SessionLocal, ApiToken
from core.middleware import (
SecurityHeadersMiddleware,
get_application_route_path,
is_cors_preflight,
path_is_route_or_child,
with_asgi_root_path,
)
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
from core.auth import AuthManager, normalize_known_username
from core.exceptions import (
SessionNotFoundError, InvalidFileUploadError,
@@ -84,7 +78,6 @@ import bcrypt as _bcrypt
from src.app_helpers import abs_join, serve_html_with_nonce
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
from src.owner_identity import auth_disabled
from starlette.responses import RedirectResponse
# ========= LOGGING =========
@@ -255,7 +248,7 @@ from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
auth_manager = AuthManager()
app.state.auth_manager = auth_manager
AUTH_ENABLED = not auth_disabled()
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() != "false"
LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true"
if LOCALHOST_BYPASS:
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
@@ -291,7 +284,7 @@ if AUTH_ENABLED:
def _is_auth_exempt(path: str) -> bool:
if path in AUTH_EXEMPT_EXACT:
return True
if any(path_is_route_or_child(path, p) for p in AUTH_EXEMPT_PREFIXES):
if any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES):
return True
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
@@ -362,7 +355,7 @@ if AUTH_ENABLED:
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = get_application_route_path(request.scope)
path = request.url.path
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
# carries no credentials by design and must reach CORSMiddleware to be
# answered. AuthMiddleware is the outermost middleware, so gating the
@@ -406,10 +399,7 @@ if AUTH_ENABLED:
if not auth_manager.is_configured:
# No users yet — redirect to login for first-time setup
if not path.startswith("/api/"):
return RedirectResponse(
url=with_asgi_root_path(request.scope, "/login"),
status_code=302,
)
return RedirectResponse(url="/login", status_code=302)
return JSONResponse(status_code=401, content={"error": "Setup required"})
# --- Bearer token auth (API tokens for external integrations) ---
@@ -471,10 +461,7 @@ if AUTH_ENABLED:
if not auth_manager.validate_token(token):
if path.startswith("/api/"):
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
return RedirectResponse(
url=with_asgi_root_path(request.scope, "/login"),
status_code=302,
)
return RedirectResponse(url="/login", status_code=302)
# Attach current username to request state for downstream routes
request.state.current_user = auth_manager.get_username_for_token(token)
@@ -643,24 +630,13 @@ app.include_router(auth_router)
@app.post("/api/activity/heartbeat")
async def activity_heartbeat():
from src.interactive_gate import (
mark_browser_activity,
maybe_stop_background_tasks_for_heartbeat,
)
from src.interactive_gate import mark_browser_activity
await mark_browser_activity()
async def _stop_background():
try:
await maybe_stop_background_tasks_for_heartbeat(
task_scheduler.stop_background_tasks_for_foreground
)
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
except Exception:
logging.getLogger("app.foreground_gate").debug(
"heartbeat task stop failed",
exc_info=True,
)
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
asyncio.create_task(_stop_background())
return {"ok": True}
-4
View File
@@ -73,10 +73,6 @@ cat > "$APP/Contents/MacOS/$APP_NAME.tmpl" <<'LAUNCHER'
INSTALL_DIR="__INSTALL_DIR__"
PORT="__PORT__"
URL="http://127.0.0.1:${PORT}"
# uvicorn is started with --port below, but APP_PORT is what the app itself
# reads when it needs to build a URL for this instance (internal_api_base(),
# companion pairing, the MCP OAuth callback), so export it as well.
export APP_PORT="$PORT"
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
UVICORN="$INSTALL_DIR/venv/bin/uvicorn"
-99
View File
@@ -6,14 +6,11 @@ units so the route layer stays thin and the logic is directly testable.
from __future__ import annotations
import ipaddress
import json
import os
import re
import secrets
import socket
import uuid
from urllib.parse import urlsplit
import bcrypt
@@ -23,102 +20,6 @@ PAIRING_VERSION = 1
COMPANION_SCOPE = "chat"
_COMPANION_IPV4_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.168.0.0/16",
)
)
_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
def _valid_companion_client_host(host: str) -> bool:
"""Match the host forms supported by the current v1 Expo client."""
if not host or len(host) > 253 or not host.isascii() or "%" in host:
return False
try:
address = ipaddress.ip_address(host)
except ValueError:
labels = host.split(".")
if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels):
return False
if any(label.startswith("xn--") for label in labels):
return False
# WHATWG URL parsers treat a decimal or ``0x`` single-label hostname
# as an IPv4 number even though Python's strict ``ipaddress`` parser
# rejects that spelling. The v1 client interpolates this host back
# into a URL, so accepting e.g. ``134744072`` would make the phone send
# its bearer token to public 8.8.8.8. Keep DNS labels unambiguous.
if len(labels) == 1 and (
labels[0].isdigit()
or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None
):
return False
return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local")
return isinstance(address, ipaddress.IPv4Address) and any(
address in network for network in _COMPANION_IPV4_NETWORKS
)
def parse_companion_base_url(value: str) -> tuple[str, int]:
"""Validate a v1 companion address and return its legacy (host, port).
The deployed client understands only HTTP plus a LAN-style host and port.
Reject anything outside that exact contract instead of advertising a URL
the client would reject, downgrade, or interpret differently.
"""
if not isinstance(value, str) or not value:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
if not value.isascii():
raise ValueError("COMPANION_BASE_URL must contain only ASCII characters")
if any(
ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"}
for char in value
):
raise ValueError(
"COMPANION_BASE_URL contains a forbidden character"
)
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as exc:
raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc
host = parsed.hostname
if parsed.scheme.lower() != "http" or not parsed.netloc or not host:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
if parsed.username is not None or parsed.password is not None:
raise ValueError("COMPANION_BASE_URL must not contain credentials")
if parsed.path or parsed.query or parsed.fragment:
raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment")
if port is not None and not 1 <= port <= 65535:
raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535")
if not _valid_companion_client_host(host):
raise ValueError("COMPANION_BASE_URL host is not supported by companion v1")
netloc = f"{host}:{port}" if port is not None else host
origin = f"http://{netloc}"
if value != origin:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
return host, port or 80
def configured_companion_origin() -> tuple[str, int] | None:
"""Return the validated operator-configured v1 address, if any."""
value = os.environ.get("COMPANION_BASE_URL")
if value is None or value == "":
return None
return parse_companion_base_url(value)
def default_port() -> int:
"""Best guess at the port the server is reachable on. Callers that know the
real request port should pass it explicitly."""
+8 -23
View File
@@ -23,7 +23,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from core.middleware import require_admin
from src.auth_helpers import _auth_disabled, get_current_user
from src.auth_helpers import get_current_user
from companion import pairing as _pairing
@@ -113,9 +113,8 @@ def setup_companion_routes() -> APIRouter:
The stock /api/models route scopes to get_current_user, which for a
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
scope to the token's real owner instead, plus legacy null-owner shared
rows -- the same rule as owner_filter. Explicit auth-disabled mode keeps
the stock route's single-user all-endpoints view. Read-only; never
returns api_key material.
rows -- the same rule as owner_filter. Read-only; never returns api_key
material.
"""
require_models_scope(request)
import json as _json
@@ -124,11 +123,6 @@ def setup_companion_routes() -> APIRouter:
from src.endpoint_resolver import build_chat_url
owner = token_owner(request)
single_user_mode = (
owner is None
and not getattr(request.state, "api_token", False)
and _auth_disabled()
)
out = []
db = SessionLocal()
try:
@@ -139,7 +133,7 @@ def setup_companion_routes() -> APIRouter:
if owner:
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
for ep in q.all():
if not single_user_mode and not owner_can_see(ep.owner, owner):
if not owner_can_see(ep.owner, owner):
continue
try:
model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
@@ -200,27 +194,19 @@ def setup_companion_routes() -> APIRouter:
the code works immediately, no restart. `?format=json` returns the
payload for an in-app pairing screen."""
require_admin(request)
try:
configured_origin = _pairing.configured_companion_origin()
except ValueError as exc:
raise HTTPException(500, str(exc)) from None
owner = get_current_user(request)
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
token_id, raw_token = mint_pairing_token(owner, invalidate)
if configured_origin:
host, port = configured_origin
hosts = [host]
else:
hosts = _pairing.lan_ip_candidates()
host = hosts[0] if hosts else "127.0.0.1"
port = request.url.port or _pairing.default_port()
hosts = _pairing.lan_ip_candidates()
host = hosts[0] if hosts else "127.0.0.1"
port = request.url.port or _pairing.default_port()
payload = _pairing.pairing_payload(host, port, raw_token)
qr = _pairing.pairing_qr_png_data_uri(payload)
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
if (request.query_params.get("format") or "").lower() == "json":
response = {
return {
"host": host,
"port": port,
"token": raw_token,
@@ -229,7 +215,6 @@ def setup_companion_routes() -> APIRouter:
"payload": payload,
"qr": qr if qr_ok else None,
}
return response
import json as _json
payload_json = _json.dumps(payload, separators=(",", ":"))
+16 -9
View File
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
from core.middleware import INTERNAL_TOOL_USER # noqa: E402
DEFAULT_PRIVILEGES = {
"can_use_agent": True,
@@ -48,18 +49,24 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
ADMIN_PRIVILEGES["block_all_models"] = False
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
from src.owner_identity import RESERVED_AUTH_USERNAMES
DEFAULT_AUTH_PATH = AUTH_FILE
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
# Usernames the auth + middleware layer reserves for request sentinels and
# internal storage owners; they must never belong to a real login account.
# "internal-tool" is the most dangerous because `core.middleware.require_admin`
# treats it as the in-process tool loopback. "api" collides with bearer-token
# attribution. "demo"/"system" are synthetic owners already special-cased by
# scheduler/assistant/research paths. The Default/Local owner is a storage
# bucket for explicit auth-disabled no-login mode, not a login username.
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
# sentinels; they must never belong to a real account. The most dangerous is
# "internal-tool": `core.middleware.require_admin` treats any request whose
# `current_user == "internal-tool"` as the in-process tool loopback and grants
# admin, and because the cookie auth path sets `current_user` to the raw
# username, an account literally named "internal-tool" would be silently
# treated as an admin by every `require_admin`-gated route. "api" collides with
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
# synthetic-owner set the rest of the codebase already special-cases (see
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
# of those names would be denied an assistant and inconsistently owner-scoped.
# Refuse to create or rename into any of them so the sentinels can't be
# impersonated. (Keep this in sync with that synthetic-owner set.)
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
+2 -19
View File
@@ -1491,25 +1491,8 @@ def _migrate_assign_legacy_owner():
with open(prefs_path, "r", encoding="utf-8") as f:
prefs = _json.load(f)
if "_users" not in prefs and prefs:
# Flat format → nest ordinary preferences under the admin
# user. Foreground fallback is an explicit per-owner opt-in,
# so auth-disabled consent must remain inert at the flat root
# rather than becoming consent for the first named owner.
foreground_keys = {
"foreground_fallback_enabled",
"foreground_model_fallbacks",
}
named_prefs = {
key: value
for key, value in prefs.items()
if key not in foreground_keys
}
new_prefs = {
key: prefs[key]
for key in foreground_keys
if key in prefs
}
new_prefs["_users"] = {admin_user: named_prefs}
# Flat format → nest under admin user
new_prefs = {"_users": {admin_user: prefs}}
with open(prefs_path, "w", encoding="utf-8") as f:
_json.dump(new_prefs, f, indent=2)
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
+3 -29
View File
@@ -3,14 +3,10 @@
import os
import secrets
from collections.abc import Mapping
from fastapi import HTTPException, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from starlette.routing import get_route_path
from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
# Per-process token that lets the in-app tool layer hit admin-gated
@@ -19,30 +15,8 @@ from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
# same value from this module. Never persisted or exposed externally.
INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
def get_application_route_path(scope: Mapping[str, object]) -> str:
"""Return the application-relative path used by Starlette routing.
Uvicorn prefixes ``scope["path"]`` with a configured ASGI ``root_path``;
Starlette removes that prefix before matching routes. Middleware policy
must use the same path form or a deployment prefix can change which policy
applies to an otherwise unchanged application route.
"""
return get_route_path(scope)
def with_asgi_root_path(scope: Mapping[str, object], path: str) -> str:
"""Prefix an application path for a client-facing redirect target."""
root_path = scope.get("root_path", "")
if not isinstance(root_path, str) or not root_path:
return path
return f"{root_path.rstrip('/')}{path}"
def path_is_route_or_child(path: str, prefix: str) -> bool:
"""Return whether ``path`` is exactly ``prefix`` or below that route."""
return path == prefix or path.startswith(prefix + "/")
# Pseudo-username on in-process tool-loopback requests; require_admin trusts it and it is reserved.
INTERNAL_TOOL_USER = "internal-tool"
def is_cors_preflight(method: str, headers) -> bool:
@@ -73,7 +47,7 @@ def require_admin(request: Request):
pass
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_disabled():
if os.getenv("AUTH_ENABLED", "true").lower() == "false":
return
if not auth_mgr or not auth_mgr.is_configured:
raise HTTPException(403, "Admin only")
-11
View File
@@ -46,7 +46,6 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
@@ -75,11 +74,6 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
@@ -135,17 +129,12 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
-11
View File
@@ -45,7 +45,6 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
@@ -74,11 +73,6 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
@@ -138,17 +132,12 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
-11
View File
@@ -34,7 +34,6 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
@@ -63,11 +62,6 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
@@ -116,17 +110,12 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
-148
View File
@@ -497,154 +497,6 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
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`.
`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
The frontend is raw ES modules with no bundler, so a page load is a few hundred
small same-origin requests. Over HTTP/1.1 browsers typically allow only a small
number of concurrent connections per host (commonly around six), so many of
those requests are serialized across multiple round trips. On localhost that
costs almost nothing. Over a LAN, VPN, or remote link it can become a major
part of load time, especially as latency increases.
HTTP/2 multiplexes them onto one connection and the serialisation disappears.
Odysseus needs no changes for this — uvicorn keeps speaking HTTP/1.1 on
loopback and the proxy speaks HTTP/2 to the browser. Mainstream browsers
negotiate HTTP/2 for normal web pages over TLS; they do not use the cleartext
h2c mode here, so browser-facing HTTP/2 requires a certificate. The
`--ssl-certfile` route in *HTTPS + LAN/Tailscale exposure* above gives you
HTTPS but not HTTP/2 — uvicorn does not speak it.
**1. Install Caddy.** See the [install docs](https://caddyserver.com/docs/install)
for your platform; on macOS, `brew install caddy`.
**2. Write a `Caddyfile`.** Pick the block that matches how you reach the
machine. Replace `7000` if Odysseus listens elsewhere — the macOS start script
uses `7860`.
Public domain, Caddy obtains and renews the certificate itself:
```
odysseus.example.com {
reverse_proxy 127.0.0.1:7000
}
```
Tailscale, no public DNS needed — `tailscale cert` issues a browser-trusted
certificate for a tailnet name and writes `<domain>.crt` and `<domain>.key`:
```bash
tailscale cert myhost.tailnet-name.ts.net
```
```
myhost.tailnet-name.ts.net {
tls /path/to/myhost.tailnet-name.ts.net.crt /path/to/myhost.tailnet-name.ts.net.key
reverse_proxy 127.0.0.1:7000
}
```
LAN with your own certificate — same shape, your own files:
```
odysseus.lan {
tls /path/to/cert.pem /path/to/key.pem
reverse_proxy 127.0.0.1:7000
}
```
Give `tls` absolute paths: a service starts in a working directory you did not
choose. If port 443 is already taken, append a port to the site address
(`odysseus.example.com:8443`) and use it in the URL. That alone does not free
port 80 — Caddy still binds it for the HTTP-to-HTTPS redirect, and fails to
start with `listen tcp :80: bind: address already in use` if something else
holds it. Turn the redirect off with a global block at the top of the file:
```
{
auto_https disable_redirects
}
```
**3. Run it in the foreground first:**
```bash
caddy run --config ./Caddyfile
```
Once that works, run it as a service:
```bash
brew services start caddy # macOS — reads $(brew --prefix)/etc/Caddyfile, not ./Caddyfile
sudo systemctl enable --now caddy # Linux, if your package installed the unit
```
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
port.
**4. Point Odysseus at the new origin** in `.env`, then restart it:
```bash
SECURE_COOKIES=true
# only if you use remote MCP servers with OAuth:
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
```
Gmail OAuth needs nothing here when the proxy runs on the same host: the
redirect URI is built from the incoming request, and uvicorn rewrites the
scheme from `X-Forwarded-Proto` for proxies it trusts — by default only
`127.0.0.1`. A proxy in a separate container or on another machine is not
trusted, so pin the URI there:
```bash
GOOGLE_OAUTH_REDIRECT_URI=https://odysseus.example.com/api/email/oauth/google/callback
```
(uvicorn's own `FORWARDED_ALLOW_IPS` widens that trust, but it has to be in the
environment uvicorn starts with — `.env` is read by the app afterwards, too
late for it to take effect.)
**5. Confirm HTTP/2 is really on:**
```bash
curl -s -o /dev/null -w '%{http_version}\n' https://odysseus.example.com/
# 2
```
The status code is not the thing to check here — a logged-out request redirects
to the login page, so `curl -I` shows `HTTP/2 302`, and the `HTTP/2` prefix is
the part that matters. The browser reports the same in the Network panel's
Protocol column (`h2`); in Chrome and Firefox that column is hidden until you
enable it by right-clicking the column headers.
Three things bite when moving an existing install behind TLS:
- Set `SECURE_COOKIES=true` **at the same time** you stop serving plain HTTP,
not before. The flag is applied to every login regardless of the scheme the
request arrived on, 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
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
origin if you use remote MCP servers over OAuth.
- Odysseus sends `Strict-Transport-Security` once it sees `X-Forwarded-Proto:
https`. HSTS applies to the whole hostname and ignores the port, so any other
plain-HTTP service on that same hostname becomes unreachable in browsers that
have visited Odysseus. Give Odysseus its own hostname, or strip the header at
the proxy (`header_down -Strict-Transport-Security` in Caddy).
Server-sent events are not buffered by this configuration, so chat streaming
arrives token by token; add `flush_interval -1` inside the `reverse_proxy`
block if you want that pinned explicitly. nginx needs `proxy_buffering off;`
for the same reason.
Changing the external origin also affects state scoped to it. Service workers
and their caches are origin-scoped, so moving to a different origin starts with
a cold load. Cookies follow their own domain/path/security rules rather than
being port-scoped: changing the hostname normally requires a new login, while
changing only the scheme or port does not by itself guarantee that existing
cookies disappear.
Common internal-only ports from the default docs/compose setup:
| Port | Service |
-4
View File
@@ -163,10 +163,6 @@ if (Test-Path $cudaBase) {
}
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
# -Port only reaches uvicorn as a flag. Everything that builds a URL for this
# instance - internal_api_base(), companion pairing, the MCP OAuth callback -
# reads APP_PORT, so set it too or they all assume 7000.
$env:APP_PORT = $Port
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
Write-Host "Press Ctrl+C to stop."
Write-Host ""
+8
View File
@@ -1802,6 +1802,7 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
from src.endpoint_resolver import (
resolve_endpoint,
resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
)
from src.llm_core import llm_call_async_with_fallback
except Exception as exc:
@@ -1842,6 +1843,13 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
utility_fallbacks = resolve_utility_fallback_candidates() or []
for cand in utility_fallbacks:
_add(*cand)
try:
chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or []
except TypeError:
chat_fallbacks = resolve_chat_fallback_candidates() or []
for cand in chat_fallbacks:
_add(*cand)
if not candidates:
return {"error": "No LLM endpoint configured for AI reply"}
+3 -4
View File
@@ -16,7 +16,7 @@ from pydantic import BaseModel
from core.database import SessionLocal, CrewMember, ScheduledTask
from src.auth_helpers import get_current_user
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from core.auth import RESERVED_USERNAMES
from src.task_scheduler import compute_next_run
@@ -90,12 +90,11 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
# check-in tasks seeded. Hitting any /assistant route under one of these
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
# owner, which then double-fired alongside the real user's check-ins.
# REQUEST_SENTINEL_OWNERS covers request-only identities; Default/Local is a
# reserved login name but remains a valid storage owner.
# RESERVED_USERNAMES covers the same set; the `not owner` guard handles "".
async def _get_or_create(owner: str) -> CrewMember:
"""Return the per-owner assistant CrewMember, creating it on demand."""
if not owner or owner in REQUEST_SENTINEL_OWNERS:
if not owner or owner in RESERVED_USERNAMES:
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
db = SessionLocal()
try:
+2 -6
View File
@@ -22,8 +22,6 @@ from src.settings import (
load_features as _load_features,
save_features as _save_features,
DEFAULT_SETTINGS,
RETIRED_SETTING_KEYS,
without_retired_settings,
)
from src.integrations import (
load_integrations,
@@ -691,7 +689,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
a scrubbed copy with secret keys blanked. The frontend uses this
for keybinds + TTS prefs, so it stays callable without admin."""
user = _get_current_user(request)
settings = without_retired_settings(_load_settings())
settings = _load_settings()
if user and auth_manager.is_admin(user):
return settings
return scrub_settings(settings)
@@ -711,8 +709,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
}
for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS:
continue
if key not in body:
continue
val = body[key]
@@ -725,7 +721,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
val = max(lo, min(val, hi))
current[key] = val
_save_settings(current)
return without_retired_settings(current)
return current
# ---- Integrations CRUD ----
+100 -47
View File
@@ -15,7 +15,7 @@ from core.database import Session as DBSession, ModelEndpoint
from src.llm_core import normalize_model_id
from src.endpoint_resolver import normalize_base
from src.context_compactor import maybe_compact, trim_for_context
from src.model_context import estimate_tokens, get_context_length
from src.model_context import estimate_tokens
from src.auth_helpers import effective_user
from src.prompt_security import untrusted_context_message
from src.attachment_refs import attachment_ref
@@ -152,38 +152,10 @@ class ChatContext:
# Uploads attached to this user turn, resolved and owner-checked for the
# agent's private context. This is not emitted to the browser.
uploaded_files: list = field(default_factory=list)
# Route-neutral prompt before any model-window compaction/trimming. This is
# retained only when explicit foreground fallbacks are enabled so each
# concrete candidate can apply its own context budget independently.
route_messages: list = field(default_factory=list)
# ── Helpers ────────────────────────────────────────────────────────────── #
def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
if privs.get("block_all_models"):
return frozenset()
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
return frozenset(model for model in allowed if isinstance(model, str)) if restricted else None
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
try:
user = effective_user(request)
except Exception:
user = None
if not user:
return None
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
if not auth_manager:
return None
privs = auth_manager.get_privileges(user) or {}
return _allowed_models_from_privileges(privs)
def _enforce_chat_privileges(request, sess) -> None:
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
@@ -213,8 +185,10 @@ def _enforce_chat_privileges(request, sess) -> None:
if privs.get("block_all_models"):
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
allowed_models = _allowed_models_from_privileges(privs)
if allowed_models is not None and sess.model and sess.model not in allowed_models:
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
if restricted and sess.model and sess.model not in allowed:
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
cap = int(privs.get("max_messages_per_day") or 0)
@@ -313,6 +287,96 @@ async def auto_name_session(session_manager, sess):
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
def try_fallback_endpoint(sess, session_id: str) -> dict | None:
"""Find an alternative working endpoint when the current one fails.
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
"""
import requests as _req
from src.endpoint_resolver import (
build_chat_url,
build_headers,
build_models_url,
normalize_base,
resolve_endpoint_runtime,
)
from src.chatgpt_subscription import is_chatgpt_subscription_base
current_url = sess.endpoint_url or ""
owner = getattr(sess, "owner", None)
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True
)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
finally:
db.close()
for ep in endpoints:
base = normalize_base(ep.base_url)
# Skip current endpoint
if current_url and base in current_url:
continue
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception:
continue
ping_url = build_models_url(base)
headers = build_headers(api_key, base)
try:
if ping_url:
r = _req.get(ping_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not models:
models = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
models = json.loads(ep.cached_models or "[]")
if not models:
continue
# Found a working endpoint — update session
new_model = models[0]
chat_url = build_chat_url(base)
new_headers = build_headers(api_key, base)
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
sess.model = new_model
sess.endpoint_url = chat_url
sess.headers = new_headers
# Persist
_db = SessionLocal()
try:
_db.query(DBSession).filter(DBSession.id == session_id).update({
"model": new_model,
"endpoint_url": chat_url,
"headers": persisted_headers,
})
_db.commit()
finally:
_db.close()
logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})")
return {
"model": new_model,
"endpoint_url": chat_url,
"endpoint_name": ep.name,
}
except Exception:
continue
return None
def extract_preset(chat_handler, preset_id) -> PresetInfo:
"""Extract preset parameters via chat_handler."""
temperature, max_tokens, system_prompt, char_name = (
@@ -623,7 +687,6 @@ async def build_chat_context(
use_enhanced_message: bool = False,
agent_mode: bool = False,
allow_tool_preprocessing: bool = True,
defer_context_shaping: bool = False,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
@@ -767,22 +830,13 @@ async def build_chat_context(
except Exception:
logger.debug("Failed to add current date/time context", exc_info=True)
route_messages = list(messages)
# Explicit fallback routing must shape from the same route-neutral prompt
# for every candidate. Running selected-model compaction here would mutate
# session history before we know which route can answer and would make a
# later larger-context candidate unable to recover discarded history.
if defer_context_shaping:
context_length = get_context_length(sess.endpoint_url, sess.model)
was_compacted = False
else:
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
# Auto-compact
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
_before_trim_messages = len(messages)
_before_trim_tokens = estimate_tokens(messages)
if not defer_context_shaping:
messages = trim_for_context(messages, context_length)
messages = trim_for_context(messages, context_length)
_after_trim_messages = len(messages)
_after_trim_tokens = estimate_tokens(messages)
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
@@ -806,7 +860,6 @@ async def build_chat_context(
context_tokens_after_trim=_after_trim_tokens,
auto_opened_docs=auto_opened_docs,
uploaded_files=uploaded_files,
route_messages=route_messages,
)
+40 -664
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -5004,6 +5004,7 @@ def setup_email_routes():
from src.endpoint_resolver import (
resolve_endpoint,
resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
)
from src.llm_core import llm_call_async_with_fallback
@@ -5065,6 +5066,8 @@ def setup_email_routes():
pass
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
if not candidates:
return {"success": False, "error": "No LLM endpoint configured"}
@@ -5324,11 +5327,13 @@ def setup_email_routes():
# Build a candidate chain so a stale session-stored API key
# (the most common cause of "authentication failed" here)
# doesn't kill AI Reply outright — fall through to the
# user's Utility / Default endpoints and active Utility fallback
# chain. Dedupe by url+model so we don't retry the same endpoint.
# user's Utility / Default endpoints and the active Utility
# fallback chain. The retired default-fallback hook stays empty.
# Dedupe by url+model so we don't retry the same broken endpoint.
from src.llm_core import llm_call_async_with_fallback
from src.endpoint_resolver import (
resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
)
_seen = set()
_candidates = []
@@ -5353,9 +5358,11 @@ def setup_email_routes():
_add(_d_url, _d_model, _d_headers)
except Exception:
pass
# Active Utility fallbacks last.
# Active Utility fallbacks, then the retired default hook.
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
_messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_msg},
+7 -13
View File
@@ -475,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
return RedirectResponse(auth_url)
else:
# Remote device — show paste-back page
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, redirect_uri))
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
finally:
db.close()
@@ -612,13 +612,15 @@ def setup_mcp_routes(mcp_manager: McpManager):
def _oauth_authorize_page(
auth_url: str,
server_id: str,
redirect_uri: str,
host: str,
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
) -> str:
"""Page with Google sign-in link and URL paste-back form for remote access."""
# Escape values interpolated into the page: `server_id` comes from the OAuth
# state and is not trusted.
# Escape values interpolated into the page: `host` comes from the request
# Host header and `server_id` from the OAuth state — neither is trusted.
auth_url = html.escape(auth_url, quote=True)
server_id = html.escape(server_id, quote=True)
host = html.escape(host, quote=True)
redirect_uri = html.escape(redirect_uri, quote=True)
return f"""<!DOCTYPE html>
<html><head>
@@ -662,15 +664,7 @@ def _oauth_authorize_page(
</div>
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
<div class="divider"></div>
<!-- Relative action: the browser resolves it against the origin this page was
served from, so the form follows the user through any proxy without the
app having to know the scheme or the host. An absolute http:// action is
blocked as mixed content on exactly the HTTPS deployments that need
paste-back, and request.url.scheme cannot be trusted to spot them —
uvicorn only honours X-Forwarded-Proto from a peer in
--forwarded-allow-ips, which defaults to 127.0.0.1 and excludes a proxy
arriving over the Docker bridge. -->
<form method="POST" action="/api/mcp/oauth/exchange/{server_id}">
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
<p>Paste the URL from your browser after signing in:</p>
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
<br><button type="submit">Connect</button>
+1 -7
View File
@@ -46,7 +46,6 @@ _ENDPOINT_SETTING_FIELDS = {
}
_ENDPOINT_FALLBACK_FIELDS = {
"foreground_model_fallbacks": "Foreground Model Fallbacks",
"utility_model_fallbacks": "Utility Model Fallbacks",
"vision_model_fallbacks": "Vision Model Fallbacks",
}
@@ -181,12 +180,7 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
if not isinstance(all_prefs, dict):
return 0
users = all_prefs.get("_users")
# A mixed store can contain auth-disabled foreground policy at the root
# alongside named-owner preferences. Both are active namespaces; legacy
# `default_model_fallbacks` remains untouched by the field allowlist.
pref_sets = [all_prefs]
if isinstance(users, dict):
pref_sets.extend(users.values())
pref_sets = users.values() if isinstance(users, dict) else [all_prefs]
cleared_users = 0
for prefs in pref_sets:
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
+11 -51
View File
@@ -7,10 +7,6 @@ from src.auth_helpers import get_current_user
from src.constants import USER_PREFS_FILE
PREFS_FILE = USER_PREFS_FILE
_FOREGROUND_POLICY_KEYS = (
"foreground_fallback_enabled",
"foreground_model_fallbacks",
)
def _load():
@@ -30,27 +26,14 @@ def _save(prefs):
def _load_for_user(user: Optional[str] = None) -> dict:
"""Load preferences for a specific user."""
all_prefs = _load()
users = all_prefs.get("_users")
if isinstance(users, dict):
if "_users" in all_prefs:
if user is None:
# Auth disabled — return first user's prefs for backward compat
prefs = dict(next(iter(users.values()), {}))
# Foreground fallback consent is never borrowed from a named
# owner. Auth-disabled operation has a separate flat/root opt-in
# that remains inert when authentication is enabled again.
for key in _FOREGROUND_POLICY_KEYS:
prefs.pop(key, None)
if key in all_prefs:
prefs[key] = all_prefs[key]
return prefs
prefs = users.get(user, {})
return dict(prefs) if isinstance(prefs, dict) else {}
# A legacy flat store belongs only to auth-disabled single-user mode.
# Copying it into the first named user's new `_users` record during an
# auth transition would silently transfer another user's preferences and,
# critically, foreground fallback consent. Named owners therefore start
# with an empty record and must write their own preferences explicitly.
return dict(all_prefs) if user is None else {}
users = all_prefs["_users"]
return dict(next(iter(users.values()), {}))
return dict(all_prefs["_users"].get(user, {}))
# Legacy flat format — return as-is
return dict(all_prefs)
def _save_for_user(user: Optional[str], prefs: dict):
@@ -62,40 +45,17 @@ def _save_for_user(user: Optional[str], prefs: dict):
# `prefs` flat would overwrite the whole `_users` map and destroy every
# other user's preferences. Instead write back into the same (first)
# slot _load_for_user(None) reads from, preserving the others.
users = all_prefs.get("_users")
if isinstance(users, dict):
if "_users" in all_prefs:
users = all_prefs["_users"]
first_key = next(iter(users), None)
if first_key is not None:
existing_named = users.get(first_key)
existing_named = (
dict(existing_named)
if isinstance(existing_named, dict)
else {}
)
named_foreground = {
key: existing_named[key]
for key in _FOREGROUND_POLICY_KEYS
if key in existing_named
}
users[first_key] = {
key: value
for key, value in prefs.items()
if key not in _FOREGROUND_POLICY_KEYS
}
users[first_key].update(named_foreground)
for key in _FOREGROUND_POLICY_KEYS:
if key in prefs:
all_prefs[key] = prefs[key]
users[first_key] = prefs
_save(all_prefs)
return
_save(prefs)
return
if not isinstance(all_prefs.get("_users"), dict):
# Preserve the flat single-user object as inert legacy data while
# creating the first named-owner namespace. In particular, historical
# fallback values must not be deleted or copied into the new owner.
all_prefs = dict(all_prefs)
all_prefs["_users"] = {}
if "_users" not in all_prefs:
all_prefs = {"_users": {}}
all_prefs["_users"][user] = prefs
_save(all_prefs)
+2 -2
View File
@@ -15,7 +15,7 @@ from pydantic import BaseModel, Field
from core.middleware import INTERNAL_TOOL_USER
from src.endpoint_resolver import resolve_endpoint
from src.auth_helpers import _auth_disabled, get_current_user
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from core.auth import RESERVED_USERNAMES
from src.constants import DEEP_RESEARCH_DIR
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
@@ -496,7 +496,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
user = require_privilege(request, "can_use_research")
if user == INTERNAL_TOOL_USER:
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
if tool_owner and tool_owner not in REQUEST_SENTINEL_OWNERS:
if tool_owner and tool_owner not in RESERVED_USERNAMES:
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
try:
+17 -251
View File
@@ -18,7 +18,6 @@ from pydantic import BaseModel, Field
from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user
from src.prompt_security import untrusted_context_message
from core.middleware import require_admin
logger = logging.getLogger(__name__)
@@ -108,23 +107,6 @@ def _skill_test_task(skill: dict) -> str:
)
def _skill_test_messages(md: str, task: str) -> list[dict]:
"""Keep user-editable skill text out of the trusted system role."""
return [
{
"role": "system",
"content": (
"You are TESTING a skill. Follow the supplied reusable procedure "
"to complete the user's task for real, using available tools step "
"by step. If the skill is wrong, unclear, or references tools that "
"do not exist, do your best; the problems will be reviewed afterward."
),
},
untrusted_context_message("skill under test", md),
{"role": "user", "content": task},
]
async def _eval_skill_run(skill_md: str, task: str, transcript: str,
url: str, model: str, headers: Optional[dict]) -> dict:
"""LLM-as-judge: grade a skill test run from its transcript. Advisory only.
@@ -429,21 +411,7 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
_skill_test_jobs: dict = {}
async def _run_skill_test_job(
key,
name,
md,
task,
url,
model,
headers,
owner,
skills_manager=None,
*,
messages=None,
transcript=None,
exact_approval=None,
):
async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, skills_manager=None):
"""Background coroutine: run the skill in an agent loop, capture a condensed
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
import json as _json
@@ -453,7 +421,7 @@ async def _run_skill_test_job(
if job is None:
return
log = job["log"]
transcript = transcript if isinstance(transcript, list) else []
transcript = []
say_buf = []
def _flush_say():
@@ -461,12 +429,18 @@ async def _run_skill_test_job(
log.append({"type": "say", "text": "".join(say_buf)})
say_buf.clear()
messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
messages = [
{"role": "system", "content":
"You are TESTING a skill. Below is a reusable skill (a procedure). Follow it "
"to complete the user's task for real, using your available tools, step by "
"step. If the skill is wrong, unclear, or references tools that don't exist, "
"do your best — the problems will be reviewed afterward.\n\n=== SKILL ===\n" + md},
{"role": "user", "content": task},
]
try:
async for chunk in stream_agent_loop(
url, model, messages, headers=headers,
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
exact_approval=exact_approval,
):
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
continue
@@ -484,25 +458,8 @@ async def _run_skill_test_job(
elif d.get("type") == "tool_output":
_flush_say()
out = str(d.get("output") or "")[:600]
tool_log = {"type": "tool_output", "output": out}
approval = d.get("ask_user")
if isinstance(approval, dict):
tool_log["ask_user"] = approval
log.append(tool_log)
log.append({"type": "tool_output", "output": out})
transcript.append(f"[output] {out}\n")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
and approval.get("approval_id")
):
# Manual skill tests have their own polling UI instead of a
# chat session. Pause the run and retain only server-side
# continuation state until the same owner approves/denies
# this exact sealed action.
job["status"] = "awaiting_approval"
job["approval"] = approval
job["_transcript"] = transcript
return
elif d.get("type") == "agent_step":
_flush_say()
log.append({"type": "agent_step", "round": d.get("round")})
@@ -514,9 +471,6 @@ async def _run_skill_test_job(
_flush_say()
log.append({"type": "error", "error": str(e)})
job.pop("approval", None)
job.pop("_transcript", None)
job.pop("_run", None)
log.append({"type": "evaluating"})
try:
job["verdict"] = await _eval_skill_run(md, task, "".join(transcript), url, model, headers)
@@ -740,8 +694,12 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
import json as _json
from src.agent_loop import stream_agent_loop
transcript = []
approval_required = None
messages = _skill_test_messages(md, task)
messages = [
{"role": "system", "content":
"You are TESTING a skill. Follow this skill's procedure to complete the task "
"for real, using your tools, step by step.\n\n=== SKILL ===\n" + md},
{"role": "user", "content": task},
]
try:
# max_tokens explicitly set: passing 0 lets some upstreams (Ollama,
# OpenAI-compat) generate an empty completion, which manifested as
@@ -761,44 +719,11 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
elif d.get("type") == "tool_output":
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
approval = d.get("ask_user")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
):
approval_required = approval
break
elif d.get("type") == "agent_step":
transcript.append(f"\n--- round {d.get('round')} ---\n")
except Exception as e:
transcript.append(f"\n[run error] {e}\n")
text = "".join(transcript)
if approval_required is not None:
# Unattended audits have no authority to approve and no UI that could
# resume this record. Destructively deny it now instead of leaving a
# reusable opaque grant pending until TTL/cap eviction.
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
approval_required.get("approval_id"),
decision="deny",
owner=owner,
session_id=None,
)
except Exception:
logger.debug("Could not retire unattended skill approval", exc_info=True)
return text, {
"verdict": "inconclusive",
"confidence": 1.0,
"summary": (
"This automated audit reached an exact action that requires "
"a human approval; no action was executed."
),
"issues": [
"Run this skill's manual test and review the sealed action."
],
"approval_required": True,
}
verdict = await _eval_skill_run(md, task, text, url, model, headers)
return text, verdict
@@ -938,26 +863,6 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
transcript, verdict = await _run_skill_test_once(md, task, url, model, headers, owner)
v = verdict.get("verdict")
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
if verdict.get("approval_required"):
# An unattended audit is not authority for an action influenced by the
# skill under test. Preserve the skill's current publication/confidence
# state and route the exact action to the manual test UI instead of
# letting a safety pause demote, rewrite, or auto-publish the skill.
skills_manager.set_audit(
name,
"inconclusive",
by_teacher=False,
worker_model=model,
owner=owner,
)
status = skill.get("status") or "draft"
log(f"{name}: {status} unchanged — exact action needs manual approval")
return {
"skill": name,
"result": "approval_required",
"verdict": verdict,
"status": status,
}
if v == "pass":
# Procedure works. If the reviewer still flagged metadata (tags/category/
# when_to_use/description), do ONE fixer pass to correct the frontmatter
@@ -1526,19 +1431,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
logger.warning(f"Skill-test model resolve failed: {_e}")
key = (user or "", name)
previous_job = _skill_test_jobs.get(key) or {}
previous_approval = previous_job.get("approval") or {}
if previous_approval.get("approval_id"):
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
previous_approval["approval_id"],
decision="deny",
owner=user,
session_id=None,
)
except Exception:
logger.debug("Could not retire replaced skill approval", exc_info=True)
_skill_test_jobs[key] = {
"status": "running",
"task": task,
@@ -1547,135 +1439,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"started": _time.time(),
"log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}],
"verdict": None,
"_run": {
"md": md,
"url": url,
"model": model,
"headers": headers,
"owner": user,
},
}
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
return {"ok": True, "status": "running", "skill": name, "model": model}
@router.post("/{skill_id}/test-approval")
async def approve_skill_test_action(request: Request, skill_id: str):
"""Resume a manual skill test with one exact server-sealed action."""
import asyncio as _asyncio
from src.tool_approvals import tool_approval_store
user = _owner(request)
skills = skills_manager.load(owner=user)
match = next(
(s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id),
None,
)
if not match:
raise HTTPException(404, "Skill not found")
_verify_owner(match, user)
name = match.get("name")
key = (user or "", name)
job = _skill_test_jobs.get(key)
if not job or job.get("status") != "awaiting_approval":
raise HTTPException(409, "This skill test is not awaiting an approval.")
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Tool approval body must be a JSON object.")
approval_id = str(body.get("approval_id") or "")
decision = str(body.get("decision") or "").strip().lower()
expected = job.get("approval") or {}
if approval_id != str(expected.get("approval_id") or ""):
raise HTTPException(409, "This approval does not match the pending skill test action.")
if decision not in {"approve", "deny"}:
raise HTTPException(400, "Invalid tool approval decision.")
pending = tool_approval_store.peek(approval_id)
normalized_owner = str(user or "").strip().casefold()
if (
pending is None
or pending.owner != normalized_owner
or pending.session_id != ""
):
raise HTTPException(409, "This tool approval is invalid or expired.")
exact_approval = tool_approval_store.consume(
approval_id,
decision=decision,
owner=user,
session_id=None,
)
if decision == "approve" and exact_approval is None:
raise HTTPException(409, "This tool approval could not be consumed.")
job.pop("approval", None)
if decision == "deny":
job.pop("_transcript", None)
job.pop("_run", None)
job["log"].append({
"type": "approval_denied",
"text": "Exact action denied; the skill test stopped without executing it.",
})
job["verdict"] = {
"verdict": "inconclusive",
"confidence": 1.0,
"summary": "The test stopped because its exact action was denied.",
"issues": [],
}
job["status"] = "done"
return {"ok": True, "status": "done", "decision": "deny"}
run = job.get("_run") or {}
transcript = job.pop("_transcript", [])
# stream_agent_loop owns its per-round message list internally. Rebuild
# continuation context from the original untrusted skill plus the
# accumulated transcript so repeated approvals do not lose earlier
# approved results, while keeping every transcript byte tainted.
messages = _skill_test_messages(
run.get("md", ""),
job.get("task", ""),
)
if transcript:
messages.append(untrusted_context_message(
"skill test transcript",
"".join(str(item) for item in transcript),
))
messages.extend([
{
"role": "assistant",
"content": str(expected.get("question") or "Allow this exact action once?"),
},
{
"role": "user",
"content": (
f"Approved the exact {exact_approval.pending.tool_name} "
"action shown above once."
),
},
])
job["status"] = "running"
job["log"].append({
"type": "approval_granted",
"text": (
f"Approved exact {exact_approval.pending.tool_name} action once; "
"resuming test."
),
})
_asyncio.create_task(_run_skill_test_job(
key,
name,
run.get("md", ""),
job.get("task", ""),
run.get("url"),
run.get("model"),
run.get("headers"),
run.get("owner"),
skills_manager,
messages=messages,
transcript=transcript,
exact_approval=exact_approval,
))
return {"ok": True, "status": "running", "decision": "approve"}
@router.get("/{skill_id}/test-status")
async def test_skill_status(request: Request, skill_id: str):
"""Current background-test state for a skill (status / log / verdict)."""
@@ -1692,7 +1459,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"model": job.get("model"),
"log": job.get("log", []),
"verdict": job.get("verdict"),
"approval": job.get("approval"),
}
@router.post("/audit-all")
-166
View File
@@ -1,166 +0,0 @@
#!/usr/bin/env python3
"""Make retained SearXNG settings inherit defaults without replacing them."""
from __future__ import annotations
import os
import stat
import sys
import tempfile
from pathlib import Path
import yaml
from yaml.nodes import MappingNode
from yaml.tokens import BlockMappingStartToken, FlowMappingStartToken
_UTF8_BOM = b"\xef\xbb\xbf"
def _parse_root_mapping(text: str) -> tuple[MappingNode | None, dict]:
"""Parse settings with the same safe YAML semantics SearXNG uses."""
try:
loaded = yaml.safe_load(text)
node = yaml.compose(text, Loader=yaml.SafeLoader)
except yaml.YAMLError:
raise ValueError("settings file is not valid single-document YAML") from None
if loaded is None and node is None:
return None, {}
if not isinstance(loaded, dict) or not isinstance(node, MappingNode):
raise ValueError("settings root is not a mapping")
return node, loaded
def _flow_mapping_start(text: str) -> int:
"""Return the root flow mapping's opening-brace character offset."""
try:
for token in yaml.scan(text, Loader=yaml.SafeLoader):
if isinstance(token, FlowMappingStartToken):
return token.start_mark.index
except yaml.YAMLError:
pass
raise ValueError("flow-style settings mapping has no opening brace")
def _newline_for(contents: bytes) -> bytes:
first_lf = contents.find(b"\n")
if first_lf > 0 and contents[first_lf - 1 : first_lf + 1] == b"\r\n":
return b"\r\n"
return b"\n"
def _block_mapping_position(text: str, root: MappingNode | None) -> tuple[int, int]:
"""Return a safe character offset and indent for a root block mapping key."""
if root is None:
return len(text), 0
try:
for token in yaml.scan(text, Loader=yaml.SafeLoader):
if not isinstance(token, BlockMappingStartToken):
continue
line_start = token.start_mark.index - token.start_mark.column
if not text[line_start : token.start_mark.index].strip():
return line_start, token.start_mark.column
return root.end_mark.index, token.start_mark.column
except yaml.YAMLError:
pass
return root.end_mark.index, root.start_mark.column
def _add_block_default_inheritance(
contents: bytes, text: str, root: MappingNode | None
) -> bytes:
newline = _newline_for(contents)
character_offset, indent_width = _block_mapping_position(text, root)
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
offset = bom_length + len(text[:character_offset].encode("utf-8"))
separator = b""
if offset not in (0, bom_length) and not contents[:offset].endswith((b"\n", b"\r")):
separator = newline
addition = (
separator
+ b" " * indent_width
+ b"use_default_settings: true"
+ newline
)
return contents[:offset] + addition + contents[offset:]
def migrate_settings(path: Path) -> bool:
"""Add the missing inheritance key atomically; return whether the file changed."""
source_stat = path.lstat()
if not stat.S_ISREG(source_stat.st_mode):
raise ValueError(f"settings path is not a regular file: {path}")
contents = path.read_bytes()
if not contents:
return False
text = contents.decode("utf-8-sig")
root, loaded = _parse_root_mapping(text)
if "use_default_settings" in loaded:
return False
if root is not None and root.flow_style:
start = _flow_mapping_start(text)
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
offset = bom_length + len(text[: start + 1].encode("utf-8"))
separator = b", " if root.value else b""
updated = (
contents[:offset]
+ b"use_default_settings: true"
+ separator
+ contents[offset:]
)
else:
updated = _add_block_default_inheritance(contents, text, root)
fd, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.odysseus-", dir=path.parent
)
temporary = Path(temporary_name)
try:
# chmod before chown: the Compose cap set is `cap_drop: ALL` plus
# CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary
# file belongs to searxng:searxng — which every retained settings file
# does, because searxng's entrypoint chowns /etc/searxng — root can no
# longer chmod it and the migration dies with EPERM.
os.fchmod(fd, stat.S_IMODE(source_stat.st_mode))
os.fchown(fd, source_stat.st_uid, source_stat.st_gid)
with os.fdopen(fd, "wb") as handle:
fd = -1
handle.write(updated)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if fd >= 0:
os.close(fd)
temporary.unlink(missing_ok=True)
return True
def main(argv: list[str]) -> int:
if len(argv) > 2:
print(f"usage: {Path(argv[0]).name} [settings.yml]", file=sys.stderr)
return 2
path = Path(argv[1]) if len(argv) == 2 else Path("/etc/searxng/settings.yml")
try:
changed = migrate_settings(path)
except (OSError, UnicodeError, ValueError) as exc:
print(f"SearXNG settings migration failed: {exc}", file=sys.stderr)
return 1
if changed:
print("Added use_default_settings inheritance to retained SearXNG settings")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+43 -213
View File
@@ -1,18 +1,16 @@
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
from __future__ import annotations
import ipaddress
import logging
import os
import time
import re
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Tuple, cast
from typing import Dict, List, Optional, Tuple
from urllib.parse import quote, urljoin, urlparse
import httpcore
import httpx
from src.url_safety import _default_resolver, check_outbound_url
from src.url_safety import check_outbound_url
logger = logging.getLogger(__name__)
@@ -27,7 +25,6 @@ TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
_GITHUB_HOSTS = frozenset({
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
})
_SKILLS_SH_HOSTS = frozenset({"skills.sh", "www.skills.sh"})
def _github_host(url: str) -> str:
@@ -75,158 +72,18 @@ def _is_text_file(name: str) -> bool:
_MAX_FETCH_REDIRECTS = 5
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
"""Parse and de-duplicate one resolver snapshot in resolver order."""
ips: List[ipaddress._BaseAddress] = []
seen = set()
for raw in raw_ips:
if not isinstance(raw, str):
continue
try:
ip = ipaddress.ip_address(raw.split("%", 1)[0])
except ValueError:
continue
if ip in seen:
continue
seen.add(ip)
ips.append(ip)
return ips
def _check_fetch_url(url: str) -> None:
"""SSRF guard for skill-import fetches (defense-in-depth).
def _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:
"""Return the exact address snapshot approved for one fetch hop."""
resolved_ips: List[str] = []
def _recording_resolver(host: str) -> List[str]:
answers = list(_default_resolver(host))
resolved_ips[:] = answers
return answers
ok, reason = check_outbound_url(
url,
block_private=True,
resolver=_recording_resolver,
)
Skill bundles only ever come from public GitHub, never an internal
address, so block private/loopback/link-local targets on every hop —
matching the hardened web-fetch path in
``services/search/content.py:_get_public_url`` rather than the lenient
default used for admin-configured model endpoints.
"""
ok, reason = check_outbound_url(url, block_private=True)
if not ok:
raise SkillImportError(f"outbound URL blocked: {reason}")
pinned_ips = _validated_ips(resolved_ips)
if not pinned_ips:
raise SkillImportError("outbound URL blocked: host did not resolve to a usable address")
return pinned_ips
# Backward compatibility alias for tests importing _check_fetch_url directly
_check_fetch_url = _resolve_and_check_url
class _PinnedBackend(httpcore.NetworkBackend):
"""Connect only to addresses from one validated DNS snapshot."""
def __init__(self, ips: List[ipaddress._BaseAddress]):
self._ips = [str(ip) for ip in ips]
self._real = httpcore.SyncBackend()
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options=None,
):
deadline = None if timeout is None else time.monotonic() + timeout
last_exc: Optional[Exception] = None
for ip in self._ips:
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
try:
return self._real.connect_tcp(
ip,
port,
remaining,
local_address,
socket_options,
)
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
last_exc = exc
if deadline is not None and time.monotonic() >= deadline:
break
if last_exc is not None:
raise last_exc
raise httpcore.ConnectError("no validated address available")
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):
"""Pin socket connects while preserving URL authority, Host, and TLS SNI."""
def __init__(self, ips: List[ipaddress._BaseAddress]):
self._pinned_ips = list(ips)
self._pool = httpcore.ConnectionPool(
ssl_context=httpx.create_ssl_context(),
http1=True,
http2=False,
network_backend=_PinnedBackend(ips),
)
def handle_request(self, request: httpx.Request) -> httpx.Response:
core_request = 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,
)
core_response = None
try:
core_response = self._pool.handle_request(core_request)
content = b"".join(cast(Iterable[bytes], core_response.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
finally:
if core_response is not None:
core_response.close()
return httpx.Response(
status_code=core_response.status,
headers=core_response.headers,
content=content,
extensions=core_response.extensions,
)
def close(self) -> None:
self._pool.close()
raise SkillImportError(reason)
def _get_checked(
@@ -243,76 +100,49 @@ def _get_checked(
hand lets us re-validate every hop, closing that blind-SSRF gap.
"""
current = url
for _ in range(_MAX_FETCH_REDIRECTS + 1):
pinned_ips = _resolve_and_check_url(current)
with httpx.Client(
transport=_PinnedTransport(pinned_ips),
follow_redirects=False,
timeout=timeout,
) as client:
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
for _ in range(_MAX_FETCH_REDIRECTS + 1):
_check_fetch_url(current)
r = client.get(current, headers=headers)
if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("location")
if not location:
return r
current = urljoin(str(r.url), location)
continue
return r
if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("location")
if not location:
return r
current = urljoin(str(r.url), location)
continue
return r
raise SkillImportError("too many redirects while fetching skill bundle")
def parse_skill_source(url: str) -> ResolvedSource:
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
url = (url or "").strip()
if not url:
raw = (url or "").strip()
if not raw:
raise SkillImportError("URL is required")
# ``urlparse`` only reports an unambiguous scheme when the URL carries the
# ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a
# schemeless ``host:port`` both parse a "scheme" that is not one, so they
# fall through to the host check below and are rejected on the host instead.
scheme = urlparse(url).scheme.lower()
if scheme not in ("http", "https"):
if scheme and url.lower().startswith(f"{scheme}://"):
raise SkillImportError(f"unsupported URL scheme: {scheme}")
# Schemeless "github.com/owner/repo" — accept only a supported host.
rough_host = (urlparse("//" + url).hostname or "").lower()
if rough_host not in _GITHUB_HOSTS and rough_host not in _SKILLS_SH_HOSTS:
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
url = "https://" + url
parsed = urlparse(url)
hostname = (parsed.hostname or "").lower()
if hostname not in _GITHUB_HOSTS and hostname not in _SKILLS_SH_HOSTS:
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
# A skills.sh link is only usable if it redirects to an exact supported
# GitHub host. Scraping the page body for a github.com link cannot work:
# skill pages only ever link the repository root, never the skill's
# subdirectory, so the scrape resolves every skill in a repo to the same
# (wrong) bundle. Fail with an actionable message instead.
if hostname in _SKILLS_SH_HOSTS:
r = _get_checked(url, timeout=20.0)
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
if "skills.sh" in raw and "github.com" not in raw:
r = _get_checked(raw, timeout=20.0)
if r.status_code >= 400:
raise _github_response_error(r)
final = str(r.url)
if _github_host(final) not in _GITHUB_HOSTS:
raise SkillImportError(
"skills.sh did not redirect to GitHub — open the skill's "
"repository on GitHub, navigate to the exact skill folder or "
"SKILL.md file, and paste that URL; the repository-root link "
"alone is not sufficient"
)
url = final
_assert_github_url(final, context="redirect target")
# Page may embed a github link; prefer final URL if redirected.
if "github.com" in final:
raw = final
else:
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
if m:
raw = m.group(0).rstrip(".,)")
# Update parsed and hostname to reflect the new GitHub URL
parsed = urlparse(url)
hostname = (parsed.hostname or "").lower()
parsed = urlparse(raw)
host = _github_host(raw)
if host not in _GITHUB_HOSTS:
raise SkillImportError(
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
)
_assert_github_url(url)
if hostname == "raw.githubusercontent.com":
if host == "raw.githubusercontent.com":
# /owner/repo/ref/path/to/file
bits = [p for p in parsed.path.split("/") if p]
if len(bits) < 4:
+463 -1635
View File
File diff suppressed because it is too large Load Diff
+26 -84
View File
@@ -17,14 +17,13 @@ close / navigation / refresh). It does NOT survive a server restart.
import asyncio
import json
import logging
import uuid
from typing import AsyncGenerator, Dict, Optional
logger = logging.getLogger(__name__)
class _Run:
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task", "run_id")
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task")
def __init__(self) -> None:
self.buffer: list = [] # ordered SSE event strings (replay log)
@@ -32,9 +31,6 @@ class _Run:
self.status: str = "running" # running | done | error | stopped
self.task: Optional[asyncio.Task] = None
self.evict_task: Optional[asyncio.Task] = None
# Stable across every subscription/replay of this exact detached run.
# The browser uses it to make local cost accounting replay-idempotent.
self.run_id: str = uuid.uuid4().hex
_RUNS: Dict[str, _Run] = {}
@@ -57,24 +53,13 @@ def _publish(run: _Run, ev: str) -> None:
pass
def _wake_run_subscribers(run: _Run) -> None:
"""Close subscribers even when the drain task never reached its body."""
for q in list(run.subscribers):
try:
q.put_nowait((None, None))
except Exception:
pass
def _schedule_evict(session_id: str, expected_run: Optional[_Run] = None) -> None:
def _schedule_evict(session_id: str) -> None:
"""(Re)arm a grace-period eviction for a terminal run with no subscribers.
Identity-checked so a run that gets replaced/reused is never evicted by a
stale timer."""
run = _RUNS.get(session_id)
if run is None:
return
if expected_run is not None and run is not expected_run:
return
if run.evict_task and not run.evict_task.done():
run.evict_task.cancel()
@@ -100,38 +85,25 @@ def get_status(session_id: str) -> Optional[str]:
return r.status if r else None
def get_run_id(session_id: str) -> Optional[str]:
"""Return the opaque identity of the current detached run, if present."""
r = _RUNS.get(session_id)
return r.run_id if r else None
def get_active_run(session_id: str) -> Optional[_Run]:
"""Return the exact active run currently registered for a session."""
r = _RUNS.get(session_id)
return r if r and r.status == "running" else None
async def _drain(session_id: str, run: _Run, agen: AsyncGenerator[str, None],
async def _drain(session_id: str, agen: AsyncGenerator[str, None],
prev_task: Optional[asyncio.Task] = None) -> None:
"""Pull every event from the wrapped generator into the run buffer, fanning
each out to live subscribers. Runs to completion regardless of subscribers."""
subscribers_woken = False
def _wake_subscribers() -> None:
nonlocal subscribers_woken
if subscribers_woken:
return
subscribers_woken = True
_wake_run_subscribers(run)
run = _RUNS.get(session_id)
if run is None:
return
# If this run replaced an in-flight one (rapid double-send), wait for that
# one to fully finish first. Its CancelledError handler calls aclose(), which
# persists its partial response — letting it complete before we start writing
# keeps the two runs' session saves sequential instead of interleaved.
try:
if prev_task is not None and not prev_task.done():
if prev_task is not None and not prev_task.done():
try:
await asyncio.wait({prev_task})
except asyncio.CancelledError:
raise # our own cancellation — propagate
except Exception:
pass
try:
async for ev in agen:
_publish(run, ev)
if run.status == "running":
@@ -144,16 +116,6 @@ async def _drain(session_id: str, run: _Run, agen: AsyncGenerator[str, None],
await agen.aclose()
except Exception:
pass
# A rapid third replacement can cancel this task while it is still
# waiting for its predecessor. Close this run's subscribers promptly,
# but keep the task alive until the predecessor finishes so the next
# run still observes the transitive session-save ordering barrier.
_wake_subscribers()
if prev_task is not None and not prev_task.done():
try:
await asyncio.shield(prev_task)
except (asyncio.CancelledError, Exception):
pass
except Exception as e:
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
run.status = "error"
@@ -165,11 +127,15 @@ async def _drain(session_id: str, run: _Run, agen: AsyncGenerator[str, None],
_publish(run, "data: [DONE]\n\n")
finally:
# Wake every subscriber with the end sentinel so their SSE closes.
_wake_subscribers()
for q in list(run.subscribers):
try:
q.put_nowait((None, None))
except Exception:
pass
# Run is terminal — arm the grace timer so it (and its buffer) is
# eventually freed even if nobody ever reconnects. subscribe() cancels
# this on connect and re-arms on disconnect.
_schedule_evict(session_id, run)
_schedule_evict(session_id)
def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
@@ -179,37 +145,20 @@ def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
prev_task: Optional[asyncio.Task] = None
if prev:
if prev.task and not prev.task.done():
# A task cancelled before its first instruction never enters
# _drain(), so its except/finally blocks cannot update status or
# wake a response already bound to this exact run. Terminalize it
# synchronously before cancelling; _drain's cleanup is idempotent
# when the task had already started.
if prev.status == "running":
prev.status = "stopped"
_wake_run_subscribers(prev)
prev.task.cancel()
prev_task = prev.task # new run awaits this before it starts writing
if prev.evict_task and not prev.evict_task.done():
prev.evict_task.cancel()
run = _Run()
_RUNS[session_id] = run
run.task = asyncio.create_task(_drain(session_id, run, agen, prev_task))
run.task = asyncio.create_task(_drain(session_id, agen, prev_task))
return run
async def subscribe(
session_id: str,
expected_run: Optional[_Run] = None,
) -> AsyncGenerator[str, None]:
async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
"""Replay the run's buffer from the start, then stream live until it ends.
Safe to call repeatedly (reconnect) and from multiple clients at once.
``expected_run`` binds a lazy StreamingResponse body to the same run whose
identity was put in its response headers. Without that binding, a rapid
replacement between response construction and body iteration could replay
the replacement run under the prior run's identity.
"""
run = expected_run or _RUNS.get(session_id)
Safe to call repeatedly (reconnect) and from multiple clients at once."""
run = _RUNS.get(session_id)
if run is None:
return
q: asyncio.Queue = asyncio.Queue()
@@ -252,19 +201,12 @@ async def subscribe(
# Last subscriber gone on a finished run — (re)arm eviction so the
# buffer doesn't linger indefinitely.
if not run.subscribers and run.status != "running":
_schedule_evict(session_id, run)
_schedule_evict(session_id)
def stop(session_id: str, expected_run_id: Optional[str] = None) -> bool:
"""Cancel the matching in-flight run (which saves its partial output).
A stale browser may issue Stop after another tab has replaced the session's
run. Once the caller knows its opaque run identity, fail closed rather than
cancelling that newer run.
"""
def stop(session_id: str) -> bool:
"""Cancel an in-flight run (the wrapped generator saves its partial)."""
run = _RUNS.get(session_id)
if not expected_run_id or run is None or run.run_id != expected_run_id:
return False
if run and run.task and not run.task.done():
run.task.cancel()
return True
+6 -18
View File
@@ -510,12 +510,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
# set/get/list/delete operate on the REAL app settings (the same store
# the Settings panel writes), so changing a model / voice / search
# engine / reminder channel from chat actually takes effect.
from src.settings import (
DEFAULT_SETTINGS,
RETIRED_SETTING_KEYS,
load_settings,
save_settings,
)
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
# Secrets/credentials the agent must NOT write: kept read-only (masked)
# so API keys never flow through chat. User sets these in the panel.
@@ -567,9 +562,6 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
return k2
return _ALIASES_SET.get(k2, (k or "").strip())
def _is_managed_key(key):
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
_ENUMS = {
"image_quality": ["low", "medium", "high"],
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
@@ -632,18 +624,14 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
if action == "list":
s = load_settings()
shown = {
k: _mask(k, v)
for k, v in s.items()
if _is_managed_key(k) and not isinstance(v, dict)
}
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
elif action == "get":
key = _resolve(args.get("key", ""))
if not key:
return {"error": "key is required", "exit_code": 1}
if not _is_managed_key(key):
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
@@ -654,11 +642,11 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
if not raw:
return {"error": "key is required", "exit_code": 1}
key = _resolve(raw)
if not _is_managed_key(key):
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
# Structured settings (dicts/lists like keybinds or vision fallbacks)
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
# have no safe scalar coercion; _coerce would pass a bare string
# straight through and clobber the structure. Refuse them here; they're
# edited in their dedicated panels. (reset/delete still restore the
@@ -687,7 +675,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
elif action == "delete" or action == "reset":
key = _resolve(args.get("key", ""))
if not _is_managed_key(key):
if key not in DEFAULT_SETTINGS:
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
-59
View File
@@ -2,7 +2,6 @@ from typing import Any, Dict, List, Optional
import logging
import re
from src.constants import MAX_READ_CHARS
from src.tool_approvals import document_content_digest
from src.tool_utils import _parse_tool_args, get_upload_handler
from src.upload_handler import reserve_upload_references
@@ -81,40 +80,6 @@ def _most_recent_owned_document(db, Document, owner: Optional[str], active_only:
return q.order_by(Document.updated_at.desc()).first()
def _approved_document_version_error(doc: Any, ctx: dict) -> Optional[Dict]:
"""Reject a sealed document action when its target changed meanwhile."""
expected_version = ctx.get("expected_document_version")
expected_digest = (
str(ctx.get("expected_document_digest") or "").strip().lower()
)
if expected_version is None and not expected_digest:
return None
try:
version_unchanged = (
expected_version is None
or int(getattr(doc, "version_count", -1)) == int(expected_version)
)
except (TypeError, ValueError):
version_unchanged = False
content_unchanged = True
if expected_digest:
content_unchanged = (
doc is not None
and document_content_digest(getattr(doc, "current_content", ""))
== expected_digest
)
if version_unchanged and content_unchanged:
return None
return {
"error": (
"The target document changed after this action was proposed. "
"Review the latest version and request the edit again."
),
"exit_code": 1,
"document_changed": True,
}
# ---------------------------------------------------------------------------
# Document tools — create/update/edit/suggest living documents
# ---------------------------------------------------------------------------
@@ -489,12 +454,6 @@ class UpdateDocumentTool:
doc = None
if target_id:
doc = _get_owned_document(db, Document, target_id, owner)
if (
not doc
and target_id
and ctx.get("expected_document_version") is not None
):
return _approved_document_version_error(None, ctx)
if not doc:
doc = _most_recent_owned_document(db, Document, owner)
if doc:
@@ -504,10 +463,6 @@ class UpdateDocumentTool:
if not doc:
return {"error": "No documents exist to update"}
version_error = _approved_document_version_error(doc, ctx)
if version_error:
return version_error
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
new_content = _coerce_email_document_content(doc.current_content or "", content) if is_email_doc else content.strip()
if is_email_doc:
@@ -575,12 +530,6 @@ class EditDocumentTool:
doc = None
if target_id:
doc = _get_owned_document(db, Document, target_id, owner)
if (
not doc
and target_id
and ctx.get("expected_document_version") is not None
):
return _approved_document_version_error(None, ctx)
if not doc:
# Fallback: most recently updated document. Avoids "no active doc" errors
# after server restart or when the agent loses track of which doc to edit.
@@ -592,10 +541,6 @@ class EditDocumentTool:
if not doc:
return {"error": "No documents exist to edit"}
version_error = _approved_document_version_error(doc, ctx)
if version_error:
return version_error
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
blank_find_edits = [e for e in edits if not (e.get("find") or "").strip()]
if blank_find_edits:
@@ -732,10 +677,6 @@ class SuggestDocumentTool:
if not doc:
return {"error": f"Document {target_id} not found"}
version_error = _approved_document_version_error(doc, ctx)
if version_error:
return version_error
# Validate that FIND text exists in document
valid = []
for s in suggestions:
+2 -8
View File
@@ -64,10 +64,7 @@ async def chat_with_model(content: str, session_id: Optional[str] = None, owner:
return {"model": model, "response": response}
except Exception as e:
logger.error(f"chat_with_model failed: {e}")
return {
"error": f"Failed to get response from {model_spec}: {e}",
"untrusted_content": True,
}
return {"error": f"Failed to get response from {model_spec}: {e}"}
async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
@@ -113,10 +110,7 @@ async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Opt
return {"model": model, "response": response, "teacher": True}
except Exception as e:
logger.error(f"ask_teacher failed: {e}")
return {
"error": f"Teacher call failed ({model_spec}): {e}",
"untrusted_content": True,
}
return {"error": f"Teacher call failed ({model_spec}): {e}"}
async def list_models(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
+1 -4
View File
@@ -240,10 +240,7 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
}
except Exception as e:
logger.error(f"send_to_session failed: {e}")
return {
"error": f"Failed to send to session: {e}",
"untrusted_content": True,
}
return {"error": f"Failed to send to session: {e}"}
async def manage_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
"""Manage sessions: rename, archive, delete, important, truncate, fork.
+1 -6
View File
@@ -66,7 +66,6 @@ class WebSearchTool:
return {
"error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}",
"exit_code": 1,
"untrusted_content": True,
}
if progress_cb:
await progress_cb({
@@ -137,11 +136,7 @@ class WebFetchTool:
if not text:
if err:
return {
"error": f"web_fetch: {url}: {err}",
"exit_code": 1,
"untrusted_content": True,
}
return {"error": f"web_fetch: {url}: {err}", "exit_code": 1}
return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1}
# Tell the model when the download budget cut the body short and how
+6 -24
View File
@@ -324,10 +324,7 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
}
except Exception as e:
logger.error(f"pipeline failed at step {len(step_outputs) + 1}: {e}")
return {
"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}",
"untrusted_content": True,
}
return {"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}"}
# ---------------------------------------------------------------------------
@@ -1092,10 +1089,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
error_text = err_json.get("error", {}).get("message", error_text) if isinstance(err_json.get("error"), dict) else str(err_json.get("error", error_text))
except Exception:
pass
return {
"error": f"Image generation failed ({resp.status_code}): {error_text}",
"untrusted_content": True,
}
return {"error": f"Image generation failed ({resp.status_code}): {error_text}"}
data = resp.json()
images = data.get("data", [])
@@ -1179,10 +1173,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
except httpx.TimeoutException:
return {"error": "Image generation timed out (300s). The model may be overloaded — try again or use quality=low."}
except Exception as e:
return {
"error": f"Image generation error: {str(e)}",
"untrusted_content": True,
}
return {"error": f"Image generation error: {str(e)}"}
async def do_edit_image(
@@ -1319,10 +1310,7 @@ async def do_edit_image(
error_text = err_json.get("detail") or err_json.get("error") or error_text
except Exception:
pass
return {
"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}",
"untrusted_content": True,
}
return {"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}"}
fallback_data = fallback_resp.json()
image_b64 = fallback_data.get("image")
if not image_b64:
@@ -1406,10 +1394,7 @@ async def do_edit_image(
"model for attached-image prompts."
)
}
return {
"error": f"Image edit failed ({resp.status_code}): {error_text}",
"untrusted_content": True,
}
return {"error": f"Image edit failed ({resp.status_code}): {error_text}"}
data = resp.json()
images = data.get("data", [])
@@ -1449,10 +1434,7 @@ async def do_edit_image(
except httpx.TimeoutException:
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
except Exception as e:
return {
"error": f"Image edit error: {str(e)}",
"untrusted_content": True,
}
return {"error": f"Image edit error: {str(e)}"}
# ---------------------------------------------------------------------------
+1 -13
View File
@@ -4,8 +4,6 @@ import os
from typing import Optional
from fastapi import Request, HTTPException
from src.owner_identity import auth_disabled, effective_storage_owner
def get_current_user(request: Request) -> Optional[str]:
"""Get current username from request state (set by auth middleware)."""
@@ -58,17 +56,7 @@ def _auth_disabled() -> bool:
"""True when the operator has explicitly turned off auth via .env.
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
three call sites agree on what "off" means."""
return auth_disabled()
def storage_owner_for_request(request: Request) -> Optional[str]:
"""Resolve the storage owner for code paths that need an owner bucket.
This does not replace route authentication. It only gives auth-disabled
no-login mode a stable storage identity instead of writing new data as
legacy NULL/ownerless state.
"""
return effective_storage_owner(effective_user(request))
return os.getenv("AUTH_ENABLED", "true").lower() == "false"
def require_user(request: Request) -> str:
+9 -20
View File
@@ -15,7 +15,6 @@ import json
import logging
from src import bg_jobs
from src.prompt_security import untrusted_context_message
logger = logging.getLogger(__name__)
@@ -26,16 +25,6 @@ POLL_INTERVAL_S = 5
_FOLLOWUP_MAX_ROUNDS = 12
def _background_result_message(rec):
inject = (
f"[Background job {rec['id']} finished]\n\n"
f"{bg_jobs.result_text(rec)}\n\n"
"Continue the task using this output. Don't repeat work that's already done. "
"If the task is now complete, give the user the final result."
)
return untrusted_context_message("background job output", inject)
async def _drain_agent(sess, messages):
"""Run the agent loop headless against a session. Returns
(final_prose, tool_events) — tool_events in the same shape the live chat
@@ -73,19 +62,13 @@ async def _drain_agent(sess, messages):
round_num = d.get("round", round_num)
elif d.get("type") == "tool_output":
# Mirror the live chat's tool_event shape (chat_routes / chatRenderer).
tool_event = {
tool_events.append({
"round": round_num,
"tool": d.get("tool"),
"command": d.get("command"),
"output": d.get("output"),
"exit_code": d.get("exit_code"),
}
if isinstance(d.get("ask_user"), dict):
# Preserve exact-approval cards from a tainted background-job
# continuation so the user can authorize the sealed action on
# the next foreground turn instead of losing it headlessly.
tool_event["ask_user"] = d["ask_user"]
tool_events.append(tool_event)
})
return full, tool_events
@@ -118,8 +101,14 @@ async def _run_followup(rec: dict) -> bool:
except Exception:
pass
inject = (
f"[Background job {rec['id']} finished]\n\n"
f"{bg_jobs.result_text(rec)}\n\n"
"Continue the task using this output. Don't repeat work that's already done. "
"If the task is now complete, give the user the final result."
)
context = sess.get_context_messages()
context.append(_background_result_message(rec))
context.append({"role": "user", "content": inject})
full, tool_events = await _drain_agent(sess, context)
+1 -15
View File
@@ -810,27 +810,13 @@ async def action_tidy_research(owner: str, **kwargs) -> Tuple[str, bool]:
Research history lives entirely in data/deep_research/<id>.json and is NOT
backed by chat-session rows — so a file must never be deleted just because
no chat session matches its id. Only prune files that fail to load.
A broken file has no readable owner stamp, so it cannot be matched against
`owner`. Clearing one is privileged: admins and the single-user operator
(AUTH_ENABLED=false) may, a regular user may not, and neither may anyone
during the pre-setup window before an admin exists.
"""
no chat session matches its id. Only prune files that fail to load."""
try:
from pathlib import Path
import json as _json
from src.tool_security import owner_is_admin_or_single_user
research_dir = Path(DEEP_RESEARCH_DIR)
if not research_dir.exists():
raise TaskNoop("no research directory")
if not owner_is_admin_or_single_user(owner):
# Return before the glob rather than filtering inside the loop: the
# loop reports "none broken" off an empty `removed`, which reaches
# Activity as a false report to a user whose files it skipped, and a
# regular user need not read every owner's file to learn it may
# delete none of them.
raise TaskNoop("not permitted to remove unattributable research files")
files = list(research_dir.glob("*.json"))
removed = []
for p in files:
+2 -9
View File
@@ -381,10 +381,7 @@ class ChatProcessor:
)
if len(rag_content) > 10000:
rag_content = rag_content[:10000] + "\n[Truncated]"
preface.append(untrusted_context_message(
"retrieved documents",
rag_content,
))
preface.append(untrusted_context_message("retrieved documents", rag_content))
except Exception as e:
logger.warning(f"RAG retrieval failed: {e}")
@@ -468,7 +465,6 @@ class ChatProcessor:
preface.append(untrusted_context_message(
f"web page: {url}",
f"Content from {url}:\n\n{content}",
provenance_origin="external",
))
# Skills index — progressive disclosure. Only injected when the
@@ -492,9 +488,6 @@ class ChatProcessor:
for s in sorted(by_cat[cat], key=lambda x: x["name"]):
desc = s.get("description") or ""
lines.append(f" - {s['name']}: {desc}" if desc else f" - {s['name']}")
preface.append(untrusted_context_message(
"available skills index",
"\n".join(lines),
))
preface.append(untrusted_context_message("available skills index", "\n".join(lines)))
return preface, rag_sources, web_sources
+2 -62
View File
@@ -282,9 +282,7 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
if essential_system:
sys_text = essential_system[0].get("content", "")
if len(sys_text) > 2000:
truncated_system = dict(essential_system[0])
truncated_system["content"] = sys_text[:2000] + "\n[System prompt truncated for context limits]"
essential_system[0] = truncated_system
essential_system[0] = {"role": "system", "content": sys_text[:2000] + "\n[System prompt truncated for context limits]"}
trimmed = essential_system + convo_msgs
if estimate_tokens(trimmed) <= budget:
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
@@ -327,9 +325,6 @@ async def maybe_compact(
messages: List[Dict],
headers: Optional[Dict] = None,
owner: Optional[str] = None,
*,
persist: bool = True,
compaction_state: Optional[Dict[str, Any]] = None,
) -> tuple:
"""Check context usage and compact if above threshold.
@@ -421,17 +416,7 @@ async def maybe_compact(
# offset — session.history INCLUDES the system messages, but
# split_point is indexed against convo_msgs which does NOT. Without
# this, the slice drops the leading system message(s).
if compaction_state is not None:
compaction_state.update({
"split_point": split_point,
"summary": summary,
"system_msg_count": len(system_msgs),
"applied": False,
})
if persist:
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
if compaction_state is not None:
compaction_state["applied"] = True
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
new_used = estimate_tokens(compacted)
logger.info(
@@ -442,51 +427,6 @@ async def maybe_compact(
return compacted, context_length, True
def apply_compaction_state(session, compaction_state: Optional[Dict[str, Any]]) -> bool:
"""Persist a route-specific compaction after that route commits output.
Candidate prompts may be compacted speculatively while an explicit
foreground fallback chain is being tried. Persisting at construction time
would let an unavailable route rewrite history before another route answers,
so callers hold this small plan and apply only the winning route's plan.
"""
state = compaction_state if isinstance(compaction_state, dict) else None
if not state or state.get("applied"):
return False
summary = state.get("summary")
split_point = state.get("split_point")
system_msg_count = state.get("system_msg_count", 0)
if not isinstance(summary, str) or not isinstance(split_point, int):
return False
_update_session_history(
session,
split_point,
summary,
system_msg_count=system_msg_count if isinstance(system_msg_count, int) else 0,
)
state["applied"] = True
return True
def apply_compaction_state_for_session(
session_id: Optional[str],
compaction_state: Optional[Dict[str, Any]],
) -> bool:
"""Resolve an in-memory session and apply a deferred compaction plan."""
if not session_id:
return False
try:
from core.models import get_session_manager_instance
manager = get_session_manager_instance()
session = manager.get_session(session_id) if manager else None
except Exception:
session = None
return apply_compaction_state(session, compaction_state) if session else False
def _update_session_history(session, split_point: int, summary: str,
system_msg_count: int = 0):
"""Update the in-memory session history after compaction.
+20 -216
View File
@@ -5,7 +5,6 @@ Consolidates the 4+ copies of normalize_base / resolve_endpoint logic into one p
"""
import json
import ipaddress
import logging
import socket
import subprocess
@@ -28,43 +27,6 @@ _NON_CHAT_MODEL = (
)
def endpoint_cost_tracked(url: str, endpoint_kind: Optional[str] = None) -> bool:
"""Return whether token cost should be tracked for a concrete route.
This is intentionally a non-secret route classification. It mirrors the
frontend's local/subscription exclusions without exposing endpoint URLs to
message metadata.
"""
try:
parsed = urlparse(url or "")
host = (parsed.hostname or "").lower().rstrip(".")
path = (parsed.path or "").rstrip("/")
except Exception:
return False
if not host:
return False
if host == "chatgpt.com" and (
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
):
return False
kind = str(endpoint_kind or "auto").strip().lower()
if kind == "local":
return False
if kind in {"api", "proxy"}:
return True
if host in {"localhost", "0.0.0.0", "host.docker.internal"} or host.endswith(".local"):
return False
try:
ip = ipaddress.ip_address(host)
return ip.is_global
except ValueError:
pass
if "." not in host:
return False
return True
def _first_chat_model(models) -> Optional[str]:
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
for m in (models or []):
@@ -434,14 +396,10 @@ def resolve_endpoint(
db.close()
def _resolve_endpoint_by_id_with_descriptor(
ep_id: str,
model: Optional[str] = None,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
def resolve_endpoint_by_id(
ep_id: str, model: Optional[str] = None, owner: Optional[str] = None
) -> Optional[Tuple[str, str, Dict]]:
"""Resolve a specific endpoint id (+ optional model) to (chat_url, model, headers).
Returns None if the endpoint doesn't exist or is disabled. Used to turn
a configured fallback entry ({endpoint_id, model}) into a dispatch target.
@@ -468,34 +426,15 @@ def _resolve_endpoint_by_id_with_descriptor(
chat_url = build_chat_url(base)
headers = build_headers(api_key, base)
m = (model or "").strip()
enabled_models = _endpoint_enabled_models(ep)
if require_exact_model:
# Explicit foreground fallback entries are concrete choices. A
# hidden or known-missing model must disable the entry instead of
# silently substituting another model from the endpoint.
if not m or m in _endpoint_hidden_models(ep):
return None
if enabled_models and m not in enabled_models:
return None
else:
# Legacy Utility/Vision chains retain their model-repair behavior.
if m and m in _endpoint_hidden_models(ep):
m = ""
if not m:
m = _first_chat_model(enabled_models) or ""
# Drop a model the user disabled on the endpoint, then pick the first
# enabled chat model rather than a hidden one.
if m and m in _endpoint_hidden_models(ep):
m = ""
if not m:
m = _first_chat_model(_endpoint_enabled_models(ep)) or ""
if not m:
return None
return (
(chat_url, m, headers),
{
"endpoint_id": ep.id,
"endpoint_label": getattr(ep, "name", None) or ep.id,
"endpoint_cost_tracked": endpoint_cost_tracked(
chat_url,
getattr(ep, "endpoint_kind", None),
),
},
)
return chat_url, m, headers
except Exception as e:
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
return None
@@ -503,101 +442,11 @@ def _resolve_endpoint_by_id_with_descriptor(
db.close()
def resolve_endpoint_by_id(
ep_id: str,
model: Optional[str] = None,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> Optional[Tuple[str, str, Dict]]:
"""Resolve a specific endpoint id (+ optional model) to its runtime route."""
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
"""Compatibility shim for the retired default-chat fallback chain."""
resolved = _resolve_endpoint_by_id_with_descriptor(
ep_id,
model,
owner=owner,
require_exact_model=require_exact_model,
)
return resolved[0] if resolved else None
def resolve_route_descriptor(
endpoint_url: str,
model: str,
headers: Optional[Dict] = None,
owner: Optional[str] = None,
) -> dict:
"""Return the visible endpoint identity for an already-resolved route.
Headers are compared only inside the process so two endpoints using the
same provider URL/model but different credentials remain distinguishable.
No credential material is returned or logged.
"""
if not endpoint_url or not model:
return {
"endpoint_id": None,
"endpoint_label": "Selected route",
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
}
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
expected = (endpoint_url.rstrip("/"), model, headers or {})
for ep in q.all():
resolved = _resolve_endpoint_by_id_with_descriptor(
ep.id,
model,
owner=owner,
require_exact_model=True,
)
if not resolved:
continue
candidate, descriptor = resolved
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
if actual == expected:
return descriptor
except Exception as e:
logger.debug("Could not identify selected endpoint route: %s", e)
finally:
db.close()
return {
"endpoint_id": None,
"endpoint_label": "Selected route",
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
}
def resolve_route_descriptor_by_id(
endpoint_id: str,
endpoint_url: str,
model: str,
headers: Optional[Dict] = None,
owner: Optional[str] = None,
) -> Optional[dict]:
"""Resolve a selected route's identity without relying on row order.
The explicit endpoint id is still verified against the resolved runtime
route. This prevents stale or mismatched request metadata from being used
for attribution while disambiguating endpoints whose routes are otherwise
identical.
"""
resolved = _resolve_endpoint_by_id_with_descriptor(
endpoint_id,
model,
owner=owner,
require_exact_model=True,
)
if not resolved:
return None
candidate, descriptor = resolved
expected = ((endpoint_url or "").rstrip("/"), model, headers or {})
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
return descriptor if actual == expected else None
del owner
return []
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
@@ -611,62 +460,17 @@ def resolve_vision_fallback_candidates(owner: Optional[str] = None) -> list:
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
out = []
try:
from src.settings import get_user_setting, load_settings
settings = load_settings()
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
except Exception:
return []
return resolve_fallback_entries(chain, owner=owner)
def resolve_fallback_entries(
entries,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> list:
"""Resolve ordered endpoint/model entries within the caller's owner scope."""
out = []
for entry in entries or []:
return out
for entry in chain:
if not isinstance(entry, dict):
continue
resolved = resolve_endpoint_by_id(
entry.get("endpoint_id", ""),
entry.get("model", ""),
owner=owner,
require_exact_model=require_exact_model,
)
if resolved and resolved not in out:
resolved = resolve_endpoint_by_id(entry.get("endpoint_id", ""), entry.get("model", ""), owner=owner)
if resolved:
out.append(resolved)
return out
def resolve_fallback_entries_with_descriptors(
entries,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> list:
"""Resolve ordered entries while retaining safe endpoint provenance."""
out = []
seen = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
resolved = _resolve_endpoint_by_id_with_descriptor(
entry.get("endpoint_id", ""),
entry.get("model", ""),
owner=owner,
require_exact_model=require_exact_model,
)
if not resolved:
continue
candidate, descriptor = resolved
if any(candidate == prior for prior in seen):
continue
seen.append(candidate)
out.append((candidate, descriptor))
return out
+14 -189
View File
@@ -1,155 +1,22 @@
"""Explicit foreground Chat and Agent model-routing policy."""
"""Foreground Chat and Agent model-routing policy.
from dataclasses import dataclass
from typing import Any, Collection, Dict, FrozenSet, Optional, Tuple
The selected session model is strict by default. Historical
``default_model_fallbacks`` values remain stored for compatibility, but this
policy intentionally does not read or migrate them.
"""
from src.endpoint_resolver import (
endpoint_cost_tracked,
resolve_fallback_entries,
resolve_fallback_entries_with_descriptors,
resolve_route_descriptor,
resolve_route_descriptor_by_id,
)
_DEFAULT_FALLBACK_ENTRY_RESOLVER = resolve_fallback_entries
FOREGROUND_FALLBACK_ENABLED_KEY = "foreground_fallback_enabled"
FOREGROUND_FALLBACK_LIST_KEY = "foreground_model_fallbacks"
FOREGROUND_AVAILABILITY_STATUSES: FrozenSet[int] = frozenset({
408, 425, 429, 500, 502, 503, 504, 507, 508, 529,
})
MAX_FOREGROUND_FALLBACKS = 10
@dataclass(frozen=True)
class ForegroundModelPolicy:
"""Resolved per-user foreground fallback policy."""
enabled: bool = False
fallback_candidates: Tuple[tuple, ...] = ()
fallback_descriptors: Tuple[dict, ...] = ()
eligible_statuses: FrozenSet[int] = FOREGROUND_AVAILABILITY_STATUSES
fallback_on_empty: bool = False
def _load_policy_preferences(owner: Optional[str]) -> dict:
"""Load only preferences that explicitly belong to ``owner``.
The generic preferences loader intentionally treats a legacy flat store as
the single-user preferences object. That compatibility must not cross an
authentication transition: once a named owner is present, foreground
fallback consent exists only in an actual ``_users[owner]`` dictionary.
"""
from routes import prefs_routes
if owner is None:
prefs = prefs_routes._load_for_user(None)
return dict(prefs) if isinstance(prefs, dict) else {}
raw = prefs_routes._load()
users = raw.get("_users") if isinstance(raw, dict) else None
if not isinstance(users, dict):
return {}
prefs = users.get(owner)
return dict(prefs) if isinstance(prefs, dict) else {}
def resolve_foreground_model_policy(
owner: Optional[str] = None,
allowed_models: Optional[Collection[str]] = None,
) -> ForegroundModelPolicy:
"""Resolve an explicit owner-scoped policy, failing closed to strict mode.
The policy is stored in user preferences even when authentication is
disabled. Historical ``default_model_fallbacks`` values are deliberately
unrelated and are never read or migrated.
"""
try:
prefs = _load_policy_preferences(owner)
except Exception:
return ForegroundModelPolicy()
if prefs.get(FOREGROUND_FALLBACK_ENABLED_KEY) is not True:
return ForegroundModelPolicy()
entries = prefs.get(FOREGROUND_FALLBACK_LIST_KEY)
if not isinstance(entries, list) or not entries:
return ForegroundModelPolicy()
if allowed_models is not None:
allowed = frozenset(allowed_models)
entries = [
entry for entry in entries
if (
isinstance(entry, dict)
and isinstance(entry.get("model"), str)
and entry.get("model") in allowed
)
]
if not entries:
return ForegroundModelPolicy()
entries = entries[:MAX_FOREGROUND_FALLBACKS]
if resolve_fallback_entries is not _DEFAULT_FALLBACK_ENTRY_RESOLVER:
# Preserve the long-standing resolver seam used by downstream tests and
# integrations. Production uses the descriptor-aware resolver below.
compatibility_candidates = resolve_fallback_entries(
entries,
owner=owner,
require_exact_model=True,
)
# Known limitation of this test-only seam: alignment matches on model
# alone, so when two entries share a model and the resolver skips the
# first, the surviving candidate inherits the skipped entry's
# endpoint_id. Production uses the descriptor-aware branch below,
# which is unaffected.
resolved_routes = []
remaining_entries = list(entries)
for candidate in compatibility_candidates:
matching_index = next(
(
index for index, entry in enumerate(remaining_entries)
if isinstance(entry, dict)
and entry.get("model") == candidate[1]
),
None,
)
matching_entry = (
remaining_entries.pop(matching_index)
if matching_index is not None
else {}
)
descriptor = {
"endpoint_id": matching_entry.get("endpoint_id"),
"endpoint_label": matching_entry.get("endpoint_id") or "Fallback route",
"endpoint_cost_tracked": endpoint_cost_tracked(candidate[0]),
}
resolved_routes.append((candidate, descriptor))
else:
resolved_routes = resolve_fallback_entries_with_descriptors(
entries,
owner=owner,
require_exact_model=True,
)
candidates = [candidate for candidate, _descriptor in resolved_routes]
if not candidates:
return ForegroundModelPolicy()
return ForegroundModelPolicy(
enabled=True,
fallback_candidates=tuple(candidates),
fallback_descriptors=tuple(
dict(descriptor) for _candidate, descriptor in resolved_routes
),
)
from typing import Any, Dict, Optional
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
"""Return only candidates explicitly enabled by the current user."""
"""Return fallback candidates for a foreground Chat or Agent request.
return list(resolve_foreground_model_policy(owner).fallback_candidates)
Foreground routing is strict, so no alternate endpoint/model is eligible.
``owner`` is accepted to keep this policy boundary owner-aware.
"""
del owner
return []
def build_foreground_model_candidates(
@@ -157,50 +24,8 @@ def build_foreground_model_candidates(
model: str,
headers: Optional[Dict[str, Any]] = None,
owner: Optional[str] = None,
policy: Optional[ForegroundModelPolicy] = None,
) -> list:
"""Build the ordered candidate list for a foreground request."""
policy = policy or resolve_foreground_model_policy(owner)
primary = (endpoint_url, model, headers or {})
candidates = [primary]
for candidate in policy.fallback_candidates:
if candidate not in candidates:
candidates.append(candidate)
return candidates
def build_foreground_route_descriptors(
endpoint_url: str,
model: str,
headers: Optional[Dict[str, Any]] = None,
owner: Optional[str] = None,
policy: Optional[ForegroundModelPolicy] = None,
selected_endpoint_id: Optional[str] = None,
) -> list:
"""Build safe route metadata parallel to foreground candidates."""
policy = policy or resolve_foreground_model_policy(owner)
selected = None
if selected_endpoint_id:
selected = resolve_route_descriptor_by_id(
selected_endpoint_id,
endpoint_url,
model,
headers or {},
owner=owner,
)
if selected is None:
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
primary = (endpoint_url, model, headers or {})
candidates = [primary]
descriptors = [selected]
for candidate, descriptor in zip(
policy.fallback_candidates,
policy.fallback_descriptors,
):
if candidate in candidates:
continue
candidates.append(candidate)
descriptors.append(dict(descriptor))
return descriptors
return [primary] + resolve_foreground_fallback_candidates(owner=owner)
+1 -8
View File
@@ -719,14 +719,7 @@ async def execute_api_call(
output = f"HTTP {status}\n{formatted}"
if status >= 400:
return {
"error": output,
"exit_code": 1,
# The error string includes the remote response body. Preserve
# it for diagnostics, but make its provenance explicit so the
# agent gate does not treat HTTP failure as content-free.
"untrusted_content": True,
}
return {"error": output, "exit_code": 1}
return {"output": output, "exit_code": 0}
-16
View File
@@ -63,11 +63,8 @@ _PASSIVE_EXACT_PATHS = {
"/api/activity/heartbeat",
"/api/client-perf",
"/api/tasks/notifications",
"/api/tasks/runs/recent",
"/api/research/active",
"/api/email/urgency-state",
# UI idle poll sibling of urgency-state; must not pre-empt background tasks.
"/api/email/unread-state",
}
_PASSIVE_PREFIXES = (
@@ -77,19 +74,6 @@ _PASSIVE_PREFIXES = (
)
async def maybe_stop_background_tasks_for_heartbeat(stop_background) -> bool:
"""Stop background work for browser activity only when the gate is enabled.
``stop_background`` is injected by the application boundary so this policy
remains independently testable without importing the full FastAPI app.
"""
if not _enabled():
return False
await stop_background(reason="browser heartbeat")
return True
def should_track_interactive_request(path: str, method: str = "GET") -> bool:
if not _enabled():
return False
+126 -885
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -530,8 +530,6 @@ class McpManager:
"stderr": output if is_error else "",
"exit_code": 1 if is_error else 0,
}
if is_error and output:
result_dict["untrusted_content"] = True
if images:
result_dict["images"] = images
return result_dict
+10 -24
View File
@@ -15,32 +15,18 @@ from urllib.parse import urlparse, parse_qs
logger = logging.getLogger(__name__)
def _resolve_redirect_base() -> str:
"""Origin the browser is sent back to after authorizing.
Falls back to the port the app binds natively (APP_PORT, read the same way
by app.py and launcher.py) rather than a fixed 7000: the macOS launcher
defaults to 7860, and a callback on the wrong port reaches nothing. The
hostname stays `localhost` rather than internal_api_base()'s 127.0.0.1 —
this URI is registered with the authorization server (via DCR, or by hand
for Google clients), so changing the host invalidates registrations that
already exist.
"""
return (
os.environ.get("OAUTH_REDIRECT_BASE_URL")
or os.environ.get("APP_PUBLIC_URL")
or f"http://localhost:{os.environ.get('APP_PORT', '7000')}"
).rstrip("/")
# OAuth redirect URI registered with every authorization server via DCR. Loopback
# is allowed for native/desktop clients (RFC 8252); remote users finish via the
# paste-back flow. Deployments whose externally reachable origin differs from the
# port Odysseus binds — reverse proxy, public domain, or Docker, whose host port
# map is invisible inside the container — must set OAUTH_REDIRECT_BASE_URL (or
# APP_PUBLIC_URL), otherwise the redirect never lands back on Odysseus.
_REDIRECT_BASE = _resolve_redirect_base()
# paste-back flow. Deployments not reachable at http://localhost:7000 (custom
# port, reverse proxy, or public domain) must set OAUTH_REDIRECT_BASE_URL (or
# APP_PUBLIC_URL) to their externally reachable origin so the redirect lands back
# on Odysseus. APP_PORT is intentionally not used: it is only the Docker host
# port-map; the app always listens on 7000 inside the container.
_REDIRECT_BASE = (
os.environ.get("OAUTH_REDIRECT_BASE_URL")
or os.environ.get("APP_PUBLIC_URL")
or "http://localhost:7000"
).rstrip("/")
REDIRECT_URI = f"{_REDIRECT_BASE}/api/mcp/oauth/callback"
# How long the background connect waits for the user to authorize before giving up.
-56
View File
@@ -1,56 +0,0 @@
"""Shared owner identity constants and helpers."""
from __future__ import annotations
import os
from typing import Optional
DEFAULT_LOCAL_OWNER = "__odysseus_local__"
DEFAULT_LOCAL_OWNER_LABEL = "Local"
INTERNAL_TOOL_USER = "internal-tool"
REQUEST_SENTINEL_OWNERS = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
RESERVED_AUTH_USERNAMES = REQUEST_SENTINEL_OWNERS | {DEFAULT_LOCAL_OWNER}
def auth_disabled() -> bool:
"""Return True only when auth is explicitly disabled by configuration."""
return os.getenv("AUTH_ENABLED", "true").strip().lower() == "false"
def normalize_owner(owner: str | None) -> Optional[str]:
"""Normalize an owner-like value without inventing a fallback identity."""
value = str(owner or "").strip()
return value or None
def owner_key(owner: str | None) -> Optional[str]:
normalized = normalize_owner(owner)
return normalized.lower() if normalized else None
def is_request_sentinel_owner(owner: str | None) -> bool:
return owner_key(owner) in REQUEST_SENTINEL_OWNERS
def effective_storage_owner(owner: str | None, *, auth_is_disabled: bool | None = None) -> Optional[str]:
"""Resolve the owner used for storage writes that need a real bucket.
``None`` still means no authenticated owner when auth is enabled. In the
explicit no-login mode, it resolves to the reserved local owner instead of
conflating local-operator writes with legacy NULL/ownerless rows.
"""
normalized = normalize_owner(owner)
if normalized:
if is_request_sentinel_owner(normalized):
return None
return normalized
disabled = auth_disabled() if auth_is_disabled is None else auth_is_disabled
if disabled:
return DEFAULT_LOCAL_OWNER
return None
def is_default_local_owner(owner: str | None) -> bool:
return owner_key(owner) == DEFAULT_LOCAL_OWNER
+2 -15
View File
@@ -61,13 +61,7 @@ def _sanitize_label(label: str) -> str:
return label
def untrusted_context_message(
label: str,
content: Any,
*,
provenance_origin: str | None = None,
arm_tool_gate: bool = True,
) -> Dict[str, Any]:
def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
"""Return an LLM message that keeps retrieved/source text out of system role.
The template is structured so that *only* the hardcoded
@@ -79,13 +73,6 @@ def untrusted_context_message(
safe_label = _sanitize_label(label)
text = "" if content is None else str(content)
text = _escape_guard_markers(text)
metadata: Dict[str, Any] = {
"trusted": False,
"source": label,
"tool_gate_untrusted": bool(arm_tool_gate),
}
if provenance_origin:
metadata["provenance_origin"] = provenance_origin
return {
"role": "user",
"content": (
@@ -95,5 +82,5 @@ def untrusted_context_message(
f"{text}\n"
f"{GUARD_CLOSE}"
),
"metadata": metadata,
"metadata": {"trusted": False, "source": label},
}
-1
View File
@@ -12,7 +12,6 @@ class ChatRequest(BaseModel):
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID")
@field_validator('message')
@classmethod
+1 -19
View File
@@ -14,13 +14,6 @@ from src.constants import SETTINGS_FILE, FEATURES_FILE
logger = logging.getLogger(__name__)
# Keys retained in the raw settings store for compatibility and rollback, but
# deliberately unavailable through generic settings APIs or agent tools. They
# must stay in ``DEFAULT_SETTINGS`` so old files continue to load without data
# loss; callers that present or mutate settings should use this set as a
# tombstone boundary.
RETIRED_SETTING_KEYS = frozenset({"default_model_fallbacks"})
# Tiny TTL cache for settings/features. get_setting() is called on hot paths
# (every chat, every preprocess); without this it re-parses the JSON each call.
# Picks up edits within _CACHE_TTL seconds, which is fine for human-edited config.
@@ -204,17 +197,6 @@ DEFAULT_SETTINGS = {
},
}
def without_retired_settings(settings: dict) -> dict:
"""Return a shallow copy suitable for generic settings interfaces."""
if not isinstance(settings, dict):
return {}
return {
key: value
for key, value in settings.items()
if key not in RETIRED_SETTING_KEYS
}
DEFAULT_FEATURES = {
"web_search": True,
"web_fetch": True,
@@ -287,7 +269,7 @@ _PER_USER_KEYS = {
# Default chat endpoint / model — without per-user resolution every new
# account inherited whatever the most-recent admin picked, which then
# got injected into the chat composer on first open.
"default_endpoint_id", "default_model",
"default_endpoint_id", "default_model", "default_model_fallbacks",
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
"research_endpoint_id", "research_model",
}
+5
View File
@@ -1,6 +1,7 @@
"""Shared resolver for background-task AI endpoints."""
from src.endpoint_resolver import (
resolve_chat_fallback_candidates,
resolve_endpoint,
resolve_utility_fallback_candidates,
)
@@ -31,6 +32,7 @@ def resolve_task_candidates(
2. Utility endpoint/model
3. Default endpoint/model
4. Utility fallback chain
5. Retired default-fallback compatibility hook (currently empty)
"""
candidates = []
@@ -47,6 +49,9 @@ def resolve_task_candidates(
_append(*resolve_endpoint("default", owner=owner))
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
_append(url, model, headers)
for url, model, headers in resolve_chat_fallback_candidates(owner=owner):
_append(url, model, headers)
return candidates
+1 -38
View File
@@ -10,7 +10,6 @@ from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable, Dict, Tuple
from core.auth import RESERVED_USERNAMES
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
@@ -1884,7 +1883,6 @@ class TaskScheduler:
pass
full_text = ""
tool_results = []
approval_pause = None
# Honor per-task max_steps (defense against runaway agent loops).
# Falls back to 20 if not set — the historical default.
@@ -1931,44 +1929,9 @@ class TaskScheduler:
tool_summary = data.get("stdout") or data.get("output") or data.get("result") or ""
if isinstance(tool_summary, str) and tool_summary.strip():
tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}")
approval = data.get("ask_user")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
):
approval_pause = {
"tool": data.get("tool") or "tool",
"approval_id": approval.get("approval_id"),
}
# Scheduled tasks have no interactive surface that
# can safely resume a one-use grant. Retire the
# record immediately instead of leaving it pending
# and report an explicit manual-action boundary.
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
approval_pause["approval_id"],
decision="deny",
owner=task.owner,
session_id=session_id,
)
except Exception:
logger.debug(
"Could not retire scheduled-task approval",
exc_info=True,
)
break
except (json.JSONDecodeError, KeyError):
pass
if approval_pause is not None:
return (
"Scheduled task paused safely: "
f"{approval_pause['tool']} requested an exact action after "
"untrusted context. That action was not executed. Run this task "
"interactively to inspect and approve the action."
)
# Grace summarization — if the model exhausted rounds on tool calls
# without producing a final text response, do one last LLM call
# asking it to summarize what it did. Guarantees output.
@@ -2521,7 +2484,7 @@ class TaskScheduler:
# check-ins seeded, which then double-fire alongside the human user's
# check-ins. This was the root cause of the duplicate 'Morning check-in'
# rows we had to manually clean up.
if not owner or owner in REQUEST_SENTINEL_OWNERS:
if not owner or owner in RESERVED_USERNAMES:
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
return
from core.database import SessionLocal, CrewMember, ScheduledTask
+74 -110
View File
@@ -439,11 +439,56 @@ async def escalate_and_learn(
failure_reason: str,
owner: Optional[str] = None,
) -> Optional[str]:
"""Retire legacy background learning when no approval UI is available."""
logger.info(
"background teacher learning skipped: generated skills require an "
"interactive exact approval"
"""Call the teacher, evaluate ITS attempt, save a skill on success.
Returns the saved skill name (or None if the teacher couldn't
write one). Logs but doesn't raise — escalation is best-effort.
"""
from src.settings import get_setting
teacher_spec = (get_setting("teacher_model", "") or "").strip()
if not teacher_spec:
return None
prompt = _TEACHER_ESCALATION_PROMPT.format(
user_request=user_request or "(no user request captured)",
failure_reason=failure_reason or "(failure reason not captured)",
untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD,
trace=_format_trace(tool_results, agent_reply),
)
response = await _call_teacher(teacher_spec, prompt, owner=owner)
if not response:
return None
skill = _extract_skill_json(response)
if not skill:
# Teacher chose not to write a skill — see prompt contract.
logger.info("teacher declined to write a skill for this failure")
return None
# Same regex eval applied to the teacher's response — if the
# teacher itself sounded uncertain ("I don't have a tool"), drop
# the skill rather than persist a sketchy one.
status, reason = evaluate_turn_regex([], response)
if status == "failure":
logger.info(f"teacher response failed eval, skipping skill save: {reason}")
return None
# Tag the skill with the escalation source for auditability.
skill.setdefault("source", "teacher-escalation")
skill.setdefault("teacher_model", teacher_spec)
# Force action=add regardless of what the teacher wrote.
skill["action"] = "add"
import json
from src.tool_implementations import do_manage_skills
try:
result = await do_manage_skills(json.dumps(skill), owner=owner)
if isinstance(result, dict) and not result.get("error"):
logger.info(f"teacher wrote skill: {skill.get('name')}")
return skill.get("name")
logger.warning(f"skill save failed: {result}")
except Exception as e:
logger.warning(f"skill save raised: {e}")
return None
@@ -518,12 +563,6 @@ async def run_teacher_inline(
student_tool_events: List[Dict[str, Any]],
student_reply: str,
owner: Optional[str] = None,
session_id: Optional[str] = None,
workspace: Optional[str] = None,
disabled_tools: Optional[set[str]] = None,
tool_policy: Any = None,
active_document: Any = None,
active_email: Optional[Dict[str, str]] = None,
):
"""Async generator. Yields SSE event strings.
@@ -622,7 +661,6 @@ async def run_teacher_inline(
from src.agent_loop import stream_agent_loop
captured_tool_events: List[Dict[str, Any]] = []
captured_text_parts: List[str] = []
captured_metrics: Dict[str, Any] = {}
async for evt_str in stream_agent_loop(
endpoint_url=teacher_url,
@@ -630,12 +668,6 @@ async def run_teacher_inline(
messages=teacher_messages,
headers=teacher_headers,
owner=owner,
session_id=session_id,
workspace=workspace,
disabled_tools=disabled_tools,
tool_policy=tool_policy,
active_document=active_document,
active_email=active_email,
_is_teacher_run=True,
):
# Swallow teacher's own [DONE] — outer loop emits the real one
@@ -650,21 +682,13 @@ async def run_teacher_inline(
if isinstance(payload, dict):
payload["teacher"] = True
typ = payload.get("type")
if typ == "metrics" and isinstance(payload.get("data"), dict):
# The outer chat route persists only the last metrics
# payload. Keep a copy so any approval produced after the
# recursive teacher run's metrics remains reloadable.
captured_metrics = dict(payload["data"])
if typ == "tool_output":
captured_tool_event = {
captured_tool_events.append({
"tool": payload.get("tool"),
"command": payload.get("command"),
"output": payload.get("output"),
"exit_code": payload.get("exit_code"),
}
if isinstance(payload.get("ask_user"), dict):
captured_tool_event["ask_user"] = payload["ask_user"]
captured_tool_events.append(captured_tool_event)
})
if "delta" in payload and isinstance(payload["delta"], str):
if payload.get("thinking"):
continue
@@ -673,12 +697,6 @@ async def run_teacher_inline(
continue
yield evt_str
# A takeover that paused for a question or exact action has not completed
# yet. Its server-owned approval card is already in the live/persisted tool
# events; do not evaluate the partial trace or distill it into a skill.
if any(event.get("ask_user") for event in captured_tool_events):
return
teacher_text = "".join(captured_text_parts).strip()
t_status, t_reason = evaluate_turn_regex(captured_tool_events, teacher_text)
if t_status == "failure":
@@ -722,85 +740,31 @@ async def run_teacher_inline(
skill.setdefault("source", "teacher-escalation")
skill.setdefault("teacher_model", teacher_spec)
if not session_id:
import json as _json
from src.tool_implementations import do_manage_skills
try:
result = await do_manage_skills(_json.dumps(skill), owner=owner)
if isinstance(result, dict) and not result.get("error"):
logger.info(f"teacher succeeded; saved skill: {skill.get('name')}")
yield (
'data: ' + json.dumps({
"type": "skill_saved",
"name": skill.get("name"),
"category": skill.get("category", "general"),
}) + '\n\n'
)
else:
yield (
'data: ' + json.dumps({
"type": "skill_save_failed",
"reason": str(result),
}) + '\n\n'
)
except Exception as e:
logger.warning(f"skill save raised: {e}")
yield (
'data: ' + json.dumps({
"type": "skill_save_failed",
"reason": (
"Teacher-generated skills require an interactive exact "
"approval before they can be saved."
),
"reason": str(e),
}) + '\n\n'
)
return
import json as _json
import uuid as _uuid
from src.tool_approvals import tool_approval_store
from src.tool_capabilities import capabilities_for_action
skill_content = _json.dumps(skill, ensure_ascii=False)
pending = tool_approval_store.create(
owner=owner,
session_id=session_id,
origin_run_id=f"teacher-skill-{_uuid.uuid4().hex}",
tool_name="manage_skills",
content=skill_content,
workspace=workspace,
external_untrusted_context_seen=True,
capabilities=capabilities_for_action("manage_skills", skill_content),
)
approval = pending.public_payload(
reason=(
"The teacher generated this reusable skill. Review and approve "
"the complete skill definition before it is saved."
),
)
persisted_metrics = dict(captured_metrics)
persisted_tool_events = list(persisted_metrics.get("tool_events") or [])
persisted_round_texts = list(persisted_metrics.get("round_texts") or [])
prior_rounds = [
event.get("round")
for event in persisted_tool_events
if isinstance(event, dict) and isinstance(event.get("round"), int)
]
approval_round = max([len(persisted_round_texts), *prior_rounds, 0]) + 1
approval_tool_event = {
"round": approval_round,
"model": teacher_model,
"tool": "manage_skills",
"command": str(skill.get("name") or "teacher-generated skill"),
"output": "Waiting for an exact user approval.",
"exit_code": None,
"ask_user": approval,
}
persisted_tool_events.append(approval_tool_event)
persisted_metrics["tool_events"] = persisted_tool_events
persisted_metrics.setdefault("model", teacher_model)
yield (
"data: "
+ json.dumps({"delta": "Review the teacher-generated skill before saving it."})
+ "\n\n"
)
yield (
"data: "
+ json.dumps({
"type": "tool_output",
**approval_tool_event,
"teacher": True,
})
+ "\n\n"
)
yield (
"data: "
+ json.dumps({"type": "ask_user", "data": approval, "teacher": True})
+ "\n\n"
)
# This must be the final metrics event: chat_routes saves only last_metrics
# when the outer stream reaches [DONE]. Without it, the live approval card
# disappears after a reload even though the server grant remains pending.
yield (
"data: "
+ json.dumps({"type": "metrics", "data": persisted_metrics, "teacher": True})
+ "\n\n"
)
-392
View File
@@ -1,392 +0,0 @@
"""Opaque, exact, one-use approvals for tainted model-requested actions.
The model may propose an action after untrusted context, but only the server
stores and later executes the exact approved tool input. Browser-visible
fields are display copies, never authority.
"""
from __future__ import annotations
import hashlib
import json
import os
import secrets
import threading
import time
from dataclasses import dataclass, field
from typing import Any
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
DEFAULT_MAX_PENDING_APPROVALS = 2048
def _normalized_owner(owner: Any) -> str:
return str(owner or "").strip().casefold()
def _normalized_workspace(workspace: Any) -> str:
if not isinstance(workspace, str) or not workspace.strip():
return ""
return os.path.realpath(os.path.expanduser(workspace))
def _canonical_digest(payload: dict[str, Any]) -> str:
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def document_content_digest(content: Any) -> str:
"""Return the stable server-side fingerprint used to seal a document."""
return hashlib.sha256(str(content or "").encode("utf-8")).hexdigest()
def _binding_payload(
*,
owner: Any,
session_id: Any,
origin_run_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
document_id: Any,
document_version: Any,
document_digest: Any,
external_untrusted_context_seen: bool,
effects: tuple[str, ...],
result_integrity: str,
) -> dict[str, Any]:
return {
"owner": _normalized_owner(owner),
"session_id": str(session_id or ""),
"origin_run_id": str(origin_run_id or ""),
"tool_name": str(tool_name or ""),
"content": str(content or ""),
"workspace": _normalized_workspace(workspace),
"document_id": str(document_id or ""),
"document_version": (
int(document_version) if document_version is not None else None
),
"document_digest": str(document_digest or "").strip().lower(),
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
"effects": list(effects),
"result_integrity": str(result_integrity),
}
@dataclass(frozen=True)
class PendingToolApproval:
approval_id: str
owner: str
session_id: str
origin_run_id: str
tool_name: str
content: str
workspace: str
document_id: str
document_version: int | None
document_digest: str
external_untrusted_context_seen: bool
effects: tuple[str, ...]
result_integrity: str
digest: str
created_at: float
expires_at: float
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
return {
"kind": "tool_approval",
"approval_id": self.approval_id,
"question": "Allow this exact action once?",
"description": reason or (
"Untrusted context influenced this run, so this action needs "
"your explicit approval."
),
"options": [
{
"label": "Allow once",
"value": "approve",
"description": "Execute only the sealed action shown here.",
},
{
"label": "Deny",
"value": "deny",
"description": "Do not execute it.",
},
],
"action": {
"tool": self.tool_name,
# Show the complete sealed input so approval never hides
# trailing lines. This is not read back as authority.
"content": self.content,
"digest": self.digest[:16],
"effects": list(self.effects),
"workspace": self.workspace or None,
"document_id": self.document_id or None,
"document_version": self.document_version,
},
}
@dataclass
class ExactToolApproval:
"""A consumed grant that the dispatcher can claim exactly once."""
pending: PendingToolApproval
_claimed: bool = field(default=False, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
def _matches_unlocked(
self,
*,
owner: Any,
session_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
) -> bool:
if self._claimed:
return False
capabilities = capabilities_for_action(tool_name, content)
effects = tuple(sorted(effect.value for effect in capabilities.effects))
result_integrity = capabilities.result_integrity.value
if (
effects != self.pending.effects
or result_integrity != self.pending.result_integrity
):
return False
expected = _binding_payload(
owner=owner,
session_id=session_id,
origin_run_id=self.pending.origin_run_id,
tool_name=tool_name,
content=content,
workspace=workspace,
document_id=self.pending.document_id,
document_version=self.pending.document_version,
document_digest=self.pending.document_digest,
external_untrusted_context_seen=(
self.pending.external_untrusted_context_seen
),
effects=effects,
result_integrity=result_integrity,
)
return _canonical_digest(expected) == self.pending.digest
def matches(
self,
*,
owner: Any,
session_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
) -> bool:
with self._lock:
return self._matches_unlocked(
owner=owner,
session_id=session_id,
tool_name=tool_name,
content=content,
workspace=workspace,
)
def claim(
self,
*,
owner: Any,
session_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
) -> bool:
with self._lock:
if not self._matches_unlocked(
owner=owner,
session_id=session_id,
tool_name=tool_name,
content=content,
workspace=workspace,
):
return False
self._claimed = True
return True
class ToolApprovalStore:
"""Thread-safe pending approval registry with destructive consumption."""
def __init__(
self,
*,
ttl_seconds: int = DEFAULT_APPROVAL_TTL_SECONDS,
max_pending: int = DEFAULT_MAX_PENDING_APPROVALS,
):
self._ttl_seconds = max(1, int(ttl_seconds))
self._max_pending = max(1, int(max_pending))
self._pending: dict[str, PendingToolApproval] = {}
self._lock = threading.Lock()
def _purge_expired_locked(self, now: float) -> None:
expired = [
approval_id
for approval_id, pending in self._pending.items()
if pending.expires_at <= now
]
for approval_id in expired:
self._pending.pop(approval_id, None)
def create(
self,
*,
owner: Any,
session_id: Any,
origin_run_id: Any,
tool_name: Any,
content: Any,
workspace: Any,
document_id: Any = None,
document_version: Any = None,
document_digest: Any = None,
external_untrusted_context_seen: bool,
capabilities: ToolCapabilities,
) -> PendingToolApproval:
now = time.time()
effects = tuple(sorted(effect.value for effect in capabilities.effects))
result_integrity = capabilities.result_integrity.value
payload = _binding_payload(
owner=owner,
session_id=session_id,
origin_run_id=origin_run_id,
tool_name=tool_name,
content=content,
workspace=workspace,
document_id=document_id,
document_version=document_version,
document_digest=document_digest,
external_untrusted_context_seen=external_untrusted_context_seen,
effects=effects,
result_integrity=result_integrity,
)
pending = PendingToolApproval(
approval_id=secrets.token_urlsafe(32),
owner=payload["owner"],
session_id=payload["session_id"],
origin_run_id=payload["origin_run_id"],
tool_name=payload["tool_name"],
content=payload["content"],
workspace=payload["workspace"],
document_id=payload["document_id"],
document_version=payload["document_version"],
document_digest=payload["document_digest"],
external_untrusted_context_seen=payload[
"external_untrusted_context_seen"
],
effects=effects,
result_integrity=result_integrity,
digest=_canonical_digest(payload),
created_at=now,
expires_at=now + self._ttl_seconds,
)
with self._lock:
self._purge_expired_locked(now)
# The chat UI exposes one pending card per session, so supersede an
# older action there. Headless/manual-test callers use an empty
# session id; keep independent origin runs separate so two skill
# tests owned by the same user cannot invalidate each other.
superseded = [
approval_id
for approval_id, existing in self._pending.items()
if (
existing.owner == pending.owner
and existing.session_id == pending.session_id
and (
bool(pending.session_id)
or existing.origin_run_id == pending.origin_run_id
)
)
]
for approval_id in superseded:
self._pending.pop(approval_id, None)
while len(self._pending) >= self._max_pending:
oldest_id = min(
self._pending,
key=lambda approval_id: self._pending[approval_id].created_at,
)
self._pending.pop(oldest_id, None)
self._pending[pending.approval_id] = pending
return pending
def consume(
self,
approval_id: Any,
*,
decision: Any,
owner: Any,
session_id: Any,
) -> ExactToolApproval | None:
now = time.time()
with self._lock:
self._purge_expired_locked(now)
approval_key = str(approval_id or "")
pending = self._pending.get(approval_key)
if pending is None:
return None
if (
pending.owner != _normalized_owner(owner)
or pending.session_id != str(session_id or "")
):
# Authentication is checked before destructive consumption so
# a leaked/guessed opaque id cannot be used to invalidate
# another owner's pending action.
return None
self._pending.pop(approval_key, None)
if str(decision or "").strip().lower() != "approve":
return None
return ExactToolApproval(pending)
def peek(self, approval_id: Any) -> PendingToolApproval | None:
now = time.time()
with self._lock:
self._purge_expired_locked(now)
return self._pending.get(str(approval_id or ""))
def retire_for_session(self, *, owner: Any, session_id: Any) -> bool:
"""Discard pending actions superseded by an ordinary user turn.
Returns whether any retired action carried external provenance, so the
caller can preserve that security state without treating the new user
message as an approval continuation.
"""
now = time.time()
normalized_owner = _normalized_owner(owner)
normalized_session = str(session_id or "")
if not normalized_session:
return False
with self._lock:
self._purge_expired_locked(now)
retired_ids = [
approval_id
for approval_id, pending in self._pending.items()
if (
pending.owner == normalized_owner
and pending.session_id == normalized_session
)
]
carried_taint = any(
self._pending[approval_id].external_untrusted_context_seen
for approval_id in retired_ids
)
for approval_id in retired_ids:
self._pending.pop(approval_id, None)
return carried_taint
tool_approval_store = ToolApprovalStore()
-668
View File
@@ -1,668 +0,0 @@
"""Deterministic capability metadata for agent tools.
Model output requests an action; it never supplies the authority for that
action. This module classifies the effects of each built-in tool and applies
run-local integrity gates before dispatch.
"""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field
from enum import Enum
from types import MappingProxyType
from typing import Any, Iterable, Mapping
from src.tool_security import BUILTIN_EMAIL_TOOLS
class ToolEffect(str, Enum):
READ_PUBLIC = "read_public"
READ_WORKSPACE = "read_workspace"
READ_PRIVATE = "read_private"
WRITE_WORKSPACE = "write_workspace"
WRITE_PRIVATE = "write_private"
EXECUTE_CODE = "execute_code"
BROKERED_NETWORK_READ = "brokered_network_read"
NETWORK_EGRESS = "network_egress"
EXTERNAL_SIDE_EFFECT = "external_side_effect"
UI_SIDE_EFFECT = "ui_side_effect"
ADMIN_CHANGE = "admin_change"
DESTRUCTIVE = "destructive"
USER_INTERACTION = "user_interaction"
class ResultIntegrity(str, Enum):
SYSTEM = "system"
WORKSPACE_UNTRUSTED = "workspace_untrusted"
EXTERNAL_UNTRUSTED = "external_untrusted"
@dataclass(frozen=True)
class ToolCapabilities:
effects: frozenset[ToolEffect]
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM
known: bool = True
def _capabilities(
*effects: ToolEffect,
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM,
) -> ToolCapabilities:
return ToolCapabilities(frozenset(effects), result_integrity)
_REGISTRY: dict[str, ToolCapabilities] = {}
def _register(
names: Iterable[str],
*effects: ToolEffect,
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM,
) -> None:
capabilities = _capabilities(*effects, result_integrity=result_integrity)
for name in names:
if name in _REGISTRY:
raise RuntimeError(f"Duplicate tool capability classification: {name}")
_REGISTRY[name] = capabilities
_register(
{"ask_user", "update_plan"},
ToolEffect.USER_INTERACTION,
)
_register(
{
"list_cached_models",
"list_cookbook_servers",
"list_downloads",
"list_models",
"list_serve_presets",
"list_served_models",
},
ToolEffect.READ_PRIVATE,
# These readers return provider-controlled model identifiers or durable
# user/admin-authored Cookbook and process state. Local brokering does not
# make the returned text server-authored.
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"search_hf_models"},
ToolEffect.BROKERED_NETWORK_READ,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"get_workspace", "glob", "grep", "ls", "read_file"},
ToolEffect.READ_WORKSPACE,
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
)
_register(
{"web_search"},
ToolEffect.BROKERED_NETWORK_READ,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"web_fetch"},
ToolEffect.BROKERED_NETWORK_READ,
ToolEffect.NETWORK_EGRESS,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{
"list_email_accounts",
"list_emails",
"read_email",
"resolve_contact",
"scan_email_unsubscribes",
"search_chats",
"search_emails",
"list_sessions",
"tail_serve_output",
"vault_get",
"vault_search",
},
ToolEffect.READ_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"bash", "manage_bg_jobs", "python"},
ToolEffect.EXECUTE_CODE,
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
)
_register(
{"apply_patch", "edit_file", "write_file"},
ToolEffect.WRITE_WORKSPACE,
# Successful writes include unified diffs that can echo arbitrary existing
# workspace content back into the next model round.
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
)
_register(
{
"create_document",
"manage_calendar",
"manage_contact",
"manage_documents",
"manage_memory",
"manage_notes",
"manage_research",
"manage_session",
"manage_skills",
"manage_tasks",
"suggest_document",
"todowrite",
},
ToolEffect.WRITE_PRIVATE,
)
_register(
{
"ai_draft_email_reply",
"create_session",
"draft_email",
"draft_email_reply",
},
ToolEffect.WRITE_PRIVATE,
# These tools resolve user-configured endpoints/accounts or read stored
# email content before returning model-visible status text.
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"edit_document", "update_document"},
ToolEffect.WRITE_PRIVATE,
# These tools can echo stored document content that was not present in
# their arguments. edit_document returns the complete edited document;
# update_document also preserves stored email headers/thread history.
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"pipeline"},
ToolEffect.NETWORK_EGRESS,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"send_to_session"},
ToolEffect.NETWORK_EGRESS,
ToolEffect.WRITE_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"chat_with_model", "ask_teacher"},
ToolEffect.NETWORK_EGRESS,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"download_attachment"},
ToolEffect.READ_PRIVATE,
ToolEffect.WRITE_WORKSPACE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"edit_image", "generate_image", "trigger_research"},
ToolEffect.NETWORK_EGRESS,
ToolEffect.WRITE_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{
"archive_email",
"bulk_email",
"mark_email_read",
"reply_to_email",
"send_email",
"unsubscribe_email",
},
ToolEffect.EXTERNAL_SIDE_EFFECT,
# Email action results can include stored headers/account labels or remote
# SMTP/IMAP responses, even when the action itself succeeded.
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"delete_email"},
ToolEffect.EXTERNAL_SIDE_EFFECT,
ToolEffect.DESTRUCTIVE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"ui_control"},
ToolEffect.UI_SIDE_EFFECT,
# Model switches and custom-theme validation read mutable user settings.
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{
"adopt_served_model",
"cancel_download",
"download_model",
"serve_model",
"serve_preset",
"stop_served_model",
"vault_unlock",
},
ToolEffect.ADMIN_CHANGE,
# Cookbook/process operations can return stored presets, provider data,
# remote shell output, and command errors.
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{
"api_call",
"app_api",
"manage_endpoints",
"manage_mcp",
"manage_settings",
"manage_tokens",
"manage_webhooks",
},
ToolEffect.ADMIN_CHANGE,
# api_call/app_api return remote or stored application data, and the
# admin managers can echo user-controlled configuration. Conservatively
# retain the action effect while treating every successful result as data.
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
TOOL_CAPABILITIES: Mapping[str, ToolCapabilities] = MappingProxyType(dict(_REGISTRY))
KNOWN_CAPABILITY_TOOLS = frozenset(TOOL_CAPABILITIES)
_UNKNOWN_CAPABILITIES = _capabilities(
ToolEffect.READ_PRIVATE,
ToolEffect.WRITE_WORKSPACE,
ToolEffect.WRITE_PRIVATE,
ToolEffect.EXECUTE_CODE,
ToolEffect.NETWORK_EGRESS,
ToolEffect.EXTERNAL_SIDE_EFFECT,
ToolEffect.ADMIN_CHANGE,
ToolEffect.DESTRUCTIVE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_UNKNOWN_CAPABILITIES = ToolCapabilities(
_UNKNOWN_CAPABILITIES.effects,
_UNKNOWN_CAPABILITIES.result_integrity,
known=False,
)
_BROWSER_MCP_READ_CAPABILITIES = _capabilities(
ToolEffect.BROKERED_NETWORK_READ,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_BROWSER_MCP_READ_TOOLS = frozenset(
{
"mcp__builtin_browser__browser_console_messages",
"mcp__builtin_browser__browser_network_requests",
"mcp__builtin_browser__browser_snapshot",
"mcp__builtin_browser__browser_take_screenshot",
}
)
def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
"""Return deterministic capabilities; malformed and unknown tools fail high."""
if not isinstance(tool_name, str) or not tool_name:
return _UNKNOWN_CAPABILITIES
capabilities = TOOL_CAPABILITIES.get(tool_name)
if capabilities is not None:
return capabilities
if tool_name.startswith("mcp__email__"):
bare_name = tool_name[len("mcp__email__"):]
capabilities = TOOL_CAPABILITIES.get(bare_name)
if bare_name in BUILTIN_EMAIL_TOOLS and capabilities is not None:
return capabilities
if tool_name in _BROWSER_MCP_READ_TOOLS:
return _BROWSER_MCP_READ_CAPABILITIES
return _UNKNOWN_CAPABILITIES
_PRIVATE_ACTION_READS: Mapping[str, frozenset[str]] = MappingProxyType(
{
"manage_calendar": frozenset({"list_calendars", "list_events"}),
"manage_contact": frozenset({"list"}),
"manage_documents": frozenset({"list", "read", "view", "open", "get"}),
"manage_memory": frozenset({"list", "search"}),
"manage_notes": frozenset({"list", "search", "find", "view"}),
"manage_research": frozenset({"list", "read", "open", "view", "get"}),
"manage_session": frozenset({"list", "switch", "open", "select", "view"}),
"manage_skills": frozenset({"list", "index", "view", "view_ref", "search"}),
"manage_tasks": frozenset({"list"}),
}
)
_PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
{
"manage_calendar": frozenset(
{"create_event", "update_event", "delete_event"}
),
"manage_contact": frozenset({"add", "update", "edit", "delete"}),
"manage_documents": frozenset({"delete", "tidy"}),
"manage_memory": frozenset({"add", "edit", "delete"}),
"manage_notes": frozenset({"add", "update", "delete", "toggle_item"}),
"manage_research": frozenset({"delete"}),
"manage_session": frozenset(
{
"rename",
"archive",
"unarchive",
"delete",
"important",
"unimportant",
"truncate",
"fork",
}
),
"manage_skills": frozenset({"add", "edit", "patch", "publish", "delete"}),
"manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}),
}
)
_ACTION_DESTRUCTIVE: Mapping[str, frozenset[str]] = MappingProxyType(
{
"manage_calendar": frozenset({"delete_event"}),
"manage_contact": frozenset({"delete"}),
"manage_documents": frozenset({"delete", "tidy"}),
"manage_endpoints": frozenset({"delete"}),
"manage_bg_jobs": frozenset({"kill", "stop", "cancel", "terminate"}),
"manage_memory": frozenset({"delete"}),
"manage_mcp": frozenset({"delete"}),
"manage_notes": frozenset({"delete"}),
"manage_research": frozenset({"delete"}),
"manage_session": frozenset({"delete", "truncate"}),
"manage_settings": frozenset({"delete", "reset"}),
"manage_skills": frozenset({"delete"}),
"manage_tasks": frozenset({"delete"}),
"manage_tokens": frozenset({"delete"}),
"manage_webhooks": frozenset({"delete"}),
}
)
_ACTION_DEFAULTS: Mapping[str, str] = MappingProxyType(
{
"manage_calendar": "list_events",
"manage_documents": "list",
"manage_research": "list",
"manage_tasks": "list",
}
)
_ACTION_ALIASES: Mapping[str, Mapping[str, str]] = MappingProxyType(
{
"manage_calendar": MappingProxyType(
{
"create": "create_event",
"update": "update_event",
"delete": "delete_event",
"list": "list_events",
}
),
"manage_notes": MappingProxyType(
{
"create": "add",
"new": "add",
"save": "add",
"remind": "add",
"reminder": "add",
"remove": "delete",
"remove_item": "toggle_item",
}
),
}
)
_LINE_ACTION_TOOLS = frozenset({"manage_memory", "manage_session"})
def _action_from_content(tool_name: str, content: Any) -> str | None:
"""Extract the action discriminator using the same accepted input shapes."""
if isinstance(content, Mapping):
payload: Any = dict(content)
elif isinstance(content, str):
raw = content.strip()
if tool_name in _LINE_ACTION_TOOLS and raw and not raw.startswith("{"):
return raw.splitlines()[0].strip().replace("-", "_").casefold() or None
try:
payload = json.loads(raw) if raw else {}
except (TypeError, ValueError):
return None
else:
payload = {}
if not isinstance(payload, dict):
return None
if (
len(payload) == 1
and isinstance(payload.get("body"), dict)
and "action" in payload["body"]
):
payload = payload["body"]
action = payload.get("action")
if (
not action
and tool_name == "manage_calendar"
and isinstance(payload.get("events"), list)
):
action = "create_event"
if not action and tool_name == "manage_tasks" and any(
payload.get(key) is not None
for key in ("task", "description", "schedule", "time", "day_of_week")
):
action = "create"
if not isinstance(action, str) or not action.strip():
action = _ACTION_DEFAULTS.get(tool_name)
if not action:
return None
normalized = action.strip().replace("-", "_").casefold()
return _ACTION_ALIASES.get(tool_name, {}).get(normalized, normalized)
def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities:
"""Classify a sealed multiplexed action; ambiguous actions fail high."""
base = capabilities_for_tool(tool_name)
if not isinstance(tool_name, str):
return base
action = _action_from_content(tool_name, content)
destructive = action in _ACTION_DESTRUCTIVE.get(tool_name, ())
if tool_name not in _PRIVATE_ACTION_READS:
if not destructive:
return base
return ToolCapabilities(
frozenset(set(base.effects) | {ToolEffect.DESTRUCTIVE}),
base.result_integrity,
known=base.known,
)
if action in _PRIVATE_ACTION_READS[tool_name]:
return _capabilities(
ToolEffect.READ_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
if action in _PRIVATE_ACTION_WRITES[tool_name]:
effects = set(base.effects)
if destructive:
effects.add(ToolEffect.DESTRUCTIVE)
return ToolCapabilities(
frozenset(effects),
ResultIntegrity.EXTERNAL_UNTRUSTED,
known=base.known,
)
return _capabilities(
ToolEffect.READ_PRIVATE,
ToolEffect.WRITE_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
def tool_result_is_successful(result: Any) -> bool:
"""Return whether a result actually introduced successful tool output."""
return bool(
isinstance(result, dict)
and not result.get("blocked")
and not result.get("approval_required")
and not result.get("error")
and result.get("exit_code") in (None, 0)
and result.get("success") is not False
)
def tool_result_should_arm_gate(
tool_name: Any,
result: Any,
content: Any = None,
) -> bool:
"""Return whether a result introduced non-system content to the model.
A blocked/approval placeholder and a genuinely content-free failure do not
change authority. Once a non-system tool returns text or structured data
that will be folded into model context, however, failure status cannot make
that payload trusted: MCP ``isError`` text, provider exception messages,
and HTTP error bodies are all attacker-controlled input surfaces.
"""
if not isinstance(result, dict):
return False
if result.get("blocked") or result.get("approval_required"):
return False
# A producer that knows a particular response body came from a remote or
# stored source overrides a coarse static SYSTEM default.
if result.get("untrusted_content") is True:
return True
capabilities = capabilities_for_action(tool_name, content)
if capabilities.result_integrity is ResultIntegrity.SYSTEM:
return False
if tool_result_is_successful(result):
return True
# ``format_tool_result`` serializes every additional structured field, so
# a fixed allowlist here would inevitably miss model-visible payloads such
# as ``details``, ``events``, or provider-specific response keys. Exclude
# only status/policy controls that carry no producer content; any other
# non-empty field crosses the same integrity boundary even on failure.
non_content_keys = frozenset(
{
"approval_required",
"blocked",
"exit_code",
"policy",
"success",
"untrusted_content",
}
)
return any(
key not in non_content_keys and value not in (None, "", [], {}, ())
for key, value in result.items()
)
POST_EXTERNAL_BLOCKED_EFFECTS = frozenset(
{
ToolEffect.READ_PRIVATE,
ToolEffect.WRITE_WORKSPACE,
ToolEffect.WRITE_PRIVATE,
ToolEffect.EXECUTE_CODE,
ToolEffect.NETWORK_EGRESS,
ToolEffect.EXTERNAL_SIDE_EFFECT,
ToolEffect.UI_SIDE_EFFECT,
ToolEffect.ADMIN_CHANGE,
ToolEffect.DESTRUCTIVE,
}
)
@dataclass(frozen=True)
class ToolGateDecision:
allowed: bool
reason: str | None = None
_EXTERNAL_MESSAGE_SOURCES = frozenset(
{
"injected research context",
"prefetched search context",
"research context",
"web search results",
"youtube transcript",
}
)
_EXTERNAL_MESSAGE_SOURCE_PREFIXES = ("web page:",)
def messages_contain_external_untrusted_context(messages: Iterable[dict]) -> bool:
"""Detect explicitly labelled external context already present in a run."""
for message in messages or ():
if not isinstance(message, dict):
continue
metadata = message.get("metadata")
if not isinstance(metadata, dict) or metadata.get("trusted") is not False:
continue
gate_marker = metadata.get("tool_gate_untrusted")
if gate_marker is True:
return True
if gate_marker is False:
# Explicit current-format opt-outs are authoritative. The source
# label heuristics below exist only for older saved wrappers that
# predate the marker.
continue
if metadata.get("provenance_origin") == "external":
return True
source = metadata.get("source")
if not isinstance(source, str):
continue
normalized_source = source.strip().casefold()
if normalized_source in _EXTERNAL_MESSAGE_SOURCES:
return True
if normalized_source.startswith(_EXTERNAL_MESSAGE_SOURCE_PREFIXES):
return True
return False
@dataclass
class ToolRunSecurityContext:
"""Server-owned integrity state for one agent run."""
external_untrusted_context_seen: bool = False
external_sources: list[str] = field(default_factory=list)
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
def observe_messages(self, messages: Iterable[dict]) -> None:
"""Promote any server-labelled untrusted prompt context into the gate."""
if messages_contain_external_untrusted_context(messages):
self.external_untrusted_context_seen = True
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
if not self.external_untrusted_context_seen:
return ToolGateDecision(True)
capabilities = capabilities_for_action(tool_name, content)
blocked_effects = capabilities.effects & POST_EXTERNAL_BLOCKED_EFFECTS
if capabilities.known and not blocked_effects:
return ToolGateDecision(True)
effects = ", ".join(sorted(effect.value for effect in blocked_effects))
if not capabilities.known:
effects = "unknown/high-impact"
return ToolGateDecision(
False,
(
"External untrusted context has already influenced this run. "
f"Tool '{tool_name}' requires a separate user-authorized action "
f"because it can cause {effects}."
),
)
def observe_tool_result(
self,
tool_name: Any,
result: Any,
content: Any = None,
) -> None:
if not tool_result_should_arm_gate(tool_name, result, content):
return
self.external_untrusted_context_seen = True
if isinstance(tool_name, str) and tool_name not in self.external_sources:
self.external_sources.append(tool_name)
def blocked_tool_result(tool_name: Any, reason: str) -> tuple[str, dict]:
return (
f"{tool_name}: BLOCKED",
{
"error": reason,
"exit_code": 1,
"blocked": True,
"policy": "external_untrusted_context",
},
)
+2 -161
View File
@@ -27,24 +27,10 @@ from src.tool_security import (
is_public_blocked_tool,
owner_is_admin_or_single_user,
)
from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result
from src.tool_approvals import ExactToolApproval
from src.tool_policy import ToolPolicy
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
from src.tool_utils import _truncate, get_mcp_manager
class _MissingToolSecurityContext:
pass
class _NoToolSecurityContext:
"""Explicit sentinel for non-agent callers that have no run provenance."""
_MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
# Persistent working directory for agent subprocesses.
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
# (/app/data) and the local data directory for manual installs.
@@ -568,19 +554,10 @@ async def _document_tool_dispatch(
content: str,
session_id: Optional[str] = None,
owner: Optional[str] = None,
document_id: Optional[str] = None,
document_version: Optional[int] = None,
document_digest: Optional[str] = None,
) -> Optional[Dict]:
"""Route a document tool through TOOL_HANDLERS with the right ctx shape."""
from src.agent_tools import TOOL_HANDLERS
ctx = {
"session_id": session_id,
"owner": owner,
"doc_id": document_id,
"expected_document_version": document_version,
"expected_document_digest": document_digest,
}
ctx = {"session_id": session_id, "owner": owner}
if tool in TOOL_HANDLERS:
return await TOOL_HANDLERS[tool](content, ctx)
return None
@@ -598,12 +575,6 @@ async def execute_tool_block(
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
tool_policy: Optional[Any] = None,
security_context: (
ToolRunSecurityContext
| _NoToolSecurityContext
| _MissingToolSecurityContext
) = _MISSING_TOOL_SECURITY_CONTEXT,
exact_approval: Optional[ExactToolApproval] = None,
) -> Tuple[str, Dict]:
"""Execute a single tool block. Returns (description, result_dict).
@@ -611,104 +582,6 @@ async def execute_tool_block(
cwd confine to it) for the duration of this call, then delegate. Reset on the
way out so the binding never leaks to the next tool call.
"""
if security_context is _MISSING_TOOL_SECURITY_CONTEXT:
raise TypeError(
"execute_tool_block requires security_context; pass a "
"ToolRunSecurityContext or NO_TOOL_SECURITY_CONTEXT explicitly"
)
if (
not isinstance(security_context, ToolRunSecurityContext)
and security_context is not NO_TOOL_SECURITY_CONTEXT
):
raise TypeError(
"security_context must be a ToolRunSecurityContext or "
"NO_TOOL_SECURITY_CONTEXT"
)
approval_claimed = False
if exact_approval is not None:
if (
not isinstance(security_context, ToolRunSecurityContext)
or not security_context.external_untrusted_context_seen
or not exact_approval.pending.external_untrusted_context_seen
):
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": "Exact-action approval requires an armed run security context.",
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
if (
exact_approval.pending.tool_name
in {"edit_document", "suggest_document", "update_document"}
and (
not exact_approval.pending.document_id
or exact_approval.pending.document_version is None
or not exact_approval.pending.document_digest
)
):
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": (
"The approved document action has no sealed target and "
"cannot be executed."
),
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
sealed_workspace = exact_approval.pending.workspace
if sealed_workspace and vet_workspace(sealed_workspace) != sealed_workspace:
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": (
"The approved workspace is no longer a valid safe "
"directory. Review the action again."
),
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
approval_claimed = exact_approval.claim(
owner=owner,
session_id=session_id,
tool_name=getattr(block, "tool_type", None),
content=getattr(block, "content", None),
workspace=workspace,
)
if not approval_claimed:
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": "The exact-action approval did not match this tool request.",
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed:
decision = security_context.decision_for(
getattr(block, "tool_type", None),
getattr(block, "content", None),
)
if not decision.allowed:
logger.warning(
"External-context policy blocked tool=%r",
getattr(block, "tool_type", None),
)
return blocked_tool_result(
getattr(block, "tool_type", None),
decision.reason or "Tool blocked by external-context policy.",
)
token = _active_workspace.set(workspace or None)
try:
output = await _execute_tool_block_impl(
@@ -718,28 +591,7 @@ async def execute_tool_block(
owner=owner,
progress_cb=progress_cb,
tool_policy=tool_policy,
approved_document_id=(
exact_approval.pending.document_id
if approval_claimed
else None
),
approved_document_version=(
exact_approval.pending.document_version
if approval_claimed
else None
),
approved_document_digest=(
exact_approval.pending.document_digest
if approval_claimed
else None
),
)
if isinstance(security_context, ToolRunSecurityContext):
security_context.observe_tool_result(
getattr(block, "tool_type", None),
output[1],
getattr(block, "content", None),
)
return output
finally:
_active_workspace.reset(token)
@@ -752,9 +604,6 @@ async def _execute_tool_block_impl(
owner: Optional[str] = None,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
tool_policy: Optional[Any] = None,
approved_document_id: Optional[str] = None,
approved_document_version: Optional[int] = None,
approved_document_digest: Optional[str] = None,
) -> Tuple[str, Dict]:
"""Execute a single tool block. Returns (description, result_dict).
@@ -916,15 +765,7 @@ async def _execute_tool_block_impl(
elif tool in ("create_document", "update_document", "edit_document",
"suggest_document", "manage_documents"):
desc = f"{tool}: {content.split(chr(10))[0][:80]}"
result = await _document_tool_dispatch(
tool,
content,
session_id,
owner,
document_id=approved_document_id,
document_version=approved_document_version,
document_digest=approved_document_digest,
) \
result = await _document_tool_dispatch(tool, content, session_id, owner) \
or {"error": f"{tool}: execution failed", "exit_code": 1}
if tool in ("edit_document", "suggest_document") and "title" in (result or {}):
desc = f"{tool}: {result.get('title', '')}"
+2 -10
View File
@@ -954,11 +954,7 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
json={"command": cmd}, headers=headers)
if resp.status_code >= 400:
return {
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
"exit_code": 1,
"untrusted_content": True,
}
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
try:
data = resp.json()
except Exception:
@@ -1087,11 +1083,7 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
json={"command": cmd}, headers=headers)
if resp.status_code >= 400:
return {
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
"exit_code": 1,
"untrusted_content": True,
}
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
data = resp.json() if resp.content else {}
output_text = (data.get("stdout") or "").strip()
stderr_text = (data.get("stderr") or "").strip()
+1 -5
View File
@@ -123,11 +123,7 @@ async def do_trigger_research(content: str, owner: Optional[str] = None) -> Dict
resp = await client.post(f"{_INTERNAL_BASE}/api/research/start",
json=payload, headers=_internal_headers(owner))
if resp.status_code >= 400:
return {
"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}",
"exit_code": 1,
"untrusted_content": True,
}
return {"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
data = resp.json()
sid = data.get("session_id", "?")
return {
-1
View File
@@ -725,7 +725,6 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
"status_code": resp.status_code,
"body": preview,
"exit_code": 1,
"untrusted_content": True,
}
return {
"output": f"{method} {path} -> {resp.status_code}\n{preview}",
-4
View File
@@ -34,10 +34,6 @@ fi
# values (APP_PORT / APP_BIND), then built-in defaults.
PORT="${ODYSSEUS_PORT:-${APP_PORT:-7860}}" # 7860, not 7000 — macOS AirPlay Receiver holds 7000.
HOST="${ODYSSEUS_HOST:-${APP_BIND:-127.0.0.1}}" # Set APP_BIND=0.0.0.0 in .env for LAN/Tailscale access.
# The port only reaches uvicorn as a flag, so export it too: everything that
# builds a URL for this instance — internal_api_base(), the companion pairing
# code, the MCP OAuth callback — reads APP_PORT and would otherwise assume 7000.
export APP_PORT="$PORT"
PROBE_HOST="$HOST"
if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then
PROBE_HOST="127.0.0.1"
+12 -11
View File
@@ -10,9 +10,9 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
import chatModule from './js/chat.js?v=20260815toolapproval4';
import chatModule from './js/chat.js?v=20260801fix1';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260815approvalsave1';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import {
@@ -22,7 +22,7 @@ import {
settleSessionHydration
} from './js/startupShell.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
@@ -33,7 +33,7 @@ import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
import calendarModule from './js/calendar.js';
import notesModule from './js/notes.js';
import adminModule from './js/admin.js?v=20260716openrouter3';
import settingsModule from './js/settings.js?v=20260815approvalsave1';
import settingsModule from './js/settings.js?v=20260722emailfastindex1';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js?v=20260723compareicon2';
// Desktop window tiling — drag a modal near an edge/corner to snap.
@@ -50,7 +50,6 @@ import * as researchPanelModule from './js/research/panel.js?v=20260630researcht
import ttsModule from './js/tts-ai.js';
import spinnerModule from './js/spinner.js';
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
import { getSettings } from './js/appConfig.js';
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js?v=20260715startupclean';
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
@@ -1519,11 +1518,13 @@ function initializeEventListeners() {
})
.catch(() => {});
// Hide Gallery when image generation is disabled in settings.
// getSettings() consumes the login prefetch itself, so every other module
// that asks for settings this load gets the same snapshot without a request.
window._initSettingsReady = getSettings()
.then(settings => {
// Hide Gallery when image generation is disabled in settings
const _prefetchedSettings = sessionStorage.getItem('ody-prefetch-settings');
sessionStorage.removeItem('ody-prefetch-settings');
window._initSettingsReady = (_prefetchedSettings
? Promise.resolve(JSON.parse(_prefetchedSettings))
: fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' }).then(r => r.json())
).then(settings => {
// NOTE: image_gen_enabled only governs *generating* images in chat — the
// tool is blocked server-side (chat_routes / agent_loop). The Gallery
// holds uploads and past images too, so it stays visible regardless;
@@ -3704,7 +3705,7 @@ function startOdysseusApp() {
modelsModule.init(API_BASE);
ragModule.init(API_BASE);
presetsModule.init(API_BASE);
searchModule.init();
searchModule.init(API_BASE);
chatModule.init(API_BASE);
chatModule.initListeners();
groupModule.init(API_BASE);
+16 -59
View File
@@ -258,8 +258,8 @@
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
<link rel="modulepreload" href="/static/app.js?v=20260815toolapproval4">
<link rel="modulepreload" href="/static/js/chat.js?v=20260815toolapproval4">
<link rel="modulepreload" href="/static/app.js?v=20260808startupshell1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
<link rel="modulepreload" href="/static/js/ui.js">
<link rel="modulepreload" href="/static/js/sessions.js">
<link rel="modulepreload" href="/static/js/markdown.js">
@@ -1421,55 +1421,6 @@
</div>
<div class="settings-layout">
<div class="settings-sidebar">
<button
type="button"
class="settings-sidebar-toggle"
id="settings-sidebar-toggle"
aria-label="Collapse settings navigation"
title="Collapse settings navigation"
>
<svg class="settings-sidebar-toggle-collapse" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
<svg class="settings-sidebar-toggle-expand" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div
class="settings-sidebar-resize-handle"
id="settings-sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize settings navigation"
aria-valuemin="150"
aria-valuemax="340"
aria-valuenow="220"
tabindex="0"
></div>
<div class="settings-sidebar-content">
<div class="settings-nav-search-wrap">
<svg class="settings-nav-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="M20 20l-4-4"></path>
</svg>
<input
type="search"
id="settings-nav-search"
class="settings-nav-search"
placeholder="Find settings…"
autocomplete="off"
aria-label="Find settings"
aria-controls="settings-nav-search-results"
/>
<div
id="settings-nav-search-results"
class="settings-nav-search-results hidden"
role="listbox"
aria-label="Settings search results"
></div>
</div>
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
<button class="settings-nav-item active" data-settings-tab="services">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
@@ -1530,10 +1481,9 @@
<span>Users</span>
</button>
<button class="settings-nav-item admin-only" data-settings-tab="system">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v-.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"/></svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>System</span>
</button>
</div>
</div>
<div class="settings-panels">
@@ -1554,6 +1504,13 @@
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
<select id="set-defaultModelSelect" class="settings-select"></select>
</div>
<div class="settings-row" style="align-items:flex-start;" hidden>
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
</div>
</div>
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
@@ -2582,20 +2539,20 @@
<script type="module" src="/static/js/search.js"></script>
<script type="module" src="/static/js/spinner.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=20260722emailfastindex1"></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=20260722emailfastindex1"></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/chat.js?v=20260815toolapproval4"></script>
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
<script type="module" src="/static/js/cookbook.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/theme.js"></script>
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260815approvalsave1"></script>
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
<script type="module" src="/static/js/assistant.js"></script>
<script type="module" src="/static/app.js?v=20260815toolapproval4"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20260808startupshell1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
+17 -65
View File
@@ -6,7 +6,6 @@ import settingsModule from './settings.js';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js';
let initialized = false;
let modalEl = null;
@@ -346,7 +345,8 @@ function initSignupToggle() {
function initShareDefaultsToggle() {
const toggle = el('adm-shareDefaultsToggle');
getSettings()
fetch('/api/auth/settings', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
.catch(e => console.warn('Settings fetch failed:', e));
toggle.addEventListener('change', async () => {
@@ -361,9 +361,6 @@ function initShareDefaultsToggle() {
toggle.checked = !!data.share_defaults_with_users;
} catch (e) {
toggle.checked = !toggle.checked;
} finally {
// Drop the shared snapshot: it still says what this toggle used to be.
invalidateSettings();
}
});
}
@@ -1896,16 +1893,8 @@ async function loadBuiltinTools() {
const list = el('adm-builtin-tools-list');
if (!list) return;
try {
// This panel is an editor, and its save posts the whole disabled list
// rebuilt from the checkboxes below. So it has to render authoritative
// state: a snapshot that went stale out of band (the manage_settings tool,
// another tab) would be re-posted wholesale on the next unrelated toggle
// and would silently undo the newer state. refreshAll() calls this on every
// panel open, so drop the shared entry and refill it. The startup read that
// chatRenderer.js shares is unaffected; this panel just never edits a cache,
// which is the same rule the settings panel follows by reading directly.
invalidateTools();
const data = await getTools();
const res = await fetch('/api/tools', { credentials: 'same-origin' });
const data = await res.json();
const tools = data.tools || [];
if (!tools.length) { list.innerHTML = '<div class="admin-empty">No tools found</div>'; return; }
@@ -1979,50 +1968,17 @@ async function loadBuiltinTools() {
});
});
// Merge only the user's intended changes onto authoritative server state.
// /api/tools replaces the full disabled list, so rebuilding it from this
// panel's DOM can undo a change made by another tab or manage_settings
// after the panel was opened.
async function _saveToolState(changes) {
invalidateTools();
const latest = await getTools();
const state = new Map(
(latest.tools || []).map(t => [t.id, !!t.enabled])
);
for (const change of changes) {
if (state.has(change.id)) {
state.set(change.id, !!change.enabled);
}
}
const disabled = Array.from(state.entries())
.filter(([, enabled]) => !enabled)
.map(([id]) => id);
try {
const res = await fetch('/api/tools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled }),
credentials: 'same-origin',
});
if (!res.ok) throw new Error(`Failed to update tools (${res.status})`);
// Bring the still-open editor forward to the same merged snapshot so an
// out-of-band change is visible instead of leaving stale checkboxes.
list.querySelectorAll('input[data-tool-id]').forEach(c => {
if (state.has(c.dataset.toolId)) {
c.checked = state.get(c.dataset.toolId);
}
});
list.querySelectorAll('.admin-tool-category').forEach(_updateCatCounter);
} finally {
// This route persists disabled_tools into the settings store
// (routes/model_routes.py), so both snapshots are now stale.
invalidateTools();
invalidateSettings();
}
// Helper: save disabled tools + update counters
async function _saveToolState() {
const allChecks = list.querySelectorAll('input[data-tool-id]');
const disabled = [];
allChecks.forEach(c => { if (!c.checked) disabled.push(c.dataset.toolId); });
await fetch('/api/tools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled }),
credentials: 'same-origin',
});
}
function _updateCatCounter(catEl) {
if (!catEl) return;
@@ -2037,9 +1993,7 @@ async function loadBuiltinTools() {
// Wire individual tool toggles
list.querySelectorAll('input[data-tool-id]').forEach(chk => {
chk.addEventListener('change', async () => {
await _saveToolState([
{ id: chk.dataset.toolId, enabled: chk.checked },
]);
await _saveToolState();
_updateCatCounter(chk.closest('.admin-tool-category'));
});
});
@@ -2050,10 +2004,8 @@ async function loadBuiltinTools() {
const catEl = chk.closest('.admin-tool-category');
if (!catEl) return;
const checked = chk.checked;
const changes = Array.from(catEl.querySelectorAll('input[data-tool-id]'))
.map(c => ({ id: c.dataset.toolId, enabled: checked }));
catEl.querySelectorAll('input[data-tool-id]').forEach(c => { c.checked = checked; });
await _saveToolState(changes);
await _saveToolState();
_updateCatCounter(catEl);
});
});
-86
View File
@@ -1,86 +0,0 @@
// static/js/appConfig.js
//
// One shared, invalidatable cache for the two config endpoints that every
// module wants at startup.
//
// Before this, /api/auth/settings was fetched independently by six modules and
// /api/tools by three, none of them aware of the others — 4 and 3 requests on a
// single cold load. Worse than the requests: each caller could observe a
// different snapshot of the same object, and chatRenderer.js is imported under
// three different ?v= query strings, so it is three separate module instances
// each issuing its own /api/tools fetch. Caching here fixes both, because the
// cache lives in one module every instance imports by the same specifier.
//
// URLs are bare paths on purpose. The callers that used `${API_BASE}/api/...`
// resolved to the identical URL — API_BASE is `window.location.origin`
// (app.js) — so nothing about the request changes for them.
//
// WRITERS MUST INVALIDATE. Anything that POSTs /api/auth/settings calls
// invalidateSettings(); anything that POSTs /api/tools calls invalidateTools()
// *and* invalidateSettings(), because that route persists `disabled_tools`
// into the same settings store (routes/model_routes.py). Miss one and the UI
// serves a stale settings object for the rest of the session, which is worse
// than the duplicate fetches this replaces.
//
// The resolved object is shared by reference, so treat it as read-only: copy
// before mutating (`{ ...await getSettings() }`).
// Written by login.html immediately before it redirects to '/', so the first
// load after a login can skip the request entirely. Consumed once per page
// load, by whichever module asks for settings first.
const PREFETCH_KEY = 'ody-prefetch-settings';
const _URLS = { settings: '/api/auth/settings', tools: '/api/tools' };
const _cache = { settings: null, tools: null };
function _readPrefetchedSettings() {
try {
const raw = sessionStorage.getItem(PREFETCH_KEY);
if (!raw) return null;
sessionStorage.removeItem(PREFETCH_KEY);
return JSON.parse(raw);
} catch (_) {
return null;
}
}
// A rejected promise must not stay in the slot. Plain `??=` memoisation would
// keep it, so one transient blip during boot would leave keybinds, TTS and the
// search provider on their defaults for the whole session with no retry. Clear
// the slot on failure — unless a later invalidate/refetch already replaced it —
// and rethrow, so every caller's existing .catch() still runs exactly as before.
function _get(key) {
if (_cache[key]) return _cache[key];
const pending = fetch(_URLS[key], { credentials: 'same-origin' })
.then(r => r.json())
.catch(err => {
if (_cache[key] === pending) _cache[key] = null;
throw err;
});
_cache[key] = pending;
return pending;
}
/** GET /api/auth/settings, once per page load (or once per invalidation). */
export function getSettings() {
if (!_cache.settings) {
const prefetched = _readPrefetchedSettings();
if (prefetched) _cache.settings = Promise.resolve(prefetched);
}
return _get('settings');
}
/** GET /api/tools, once per page load (or once per invalidation). */
export function getTools() {
return _get('tools');
}
/** Call after any write that can change settings. */
export function invalidateSettings() {
_cache.settings = null;
}
/** Call after any write that can change the tool enable/disable state. */
export function invalidateTools() {
_cache.tools = null;
}
+197 -642
View File
File diff suppressed because it is too large Load Diff
-104
View File
@@ -1,104 +0,0 @@
/** Select and update the response holder for a route-provenance event. */
export function applyModelRouteEventState(event, holder, roundHolder, defaultModel = '') {
const target = event && event.round && roundHolder ? roundHolder : holder;
if (!target) return null;
target._requestedModel = (
event.requested_model
|| event.selected_model
|| target._requestedModel
|| defaultModel
);
target._actualModel = (
event.model
|| event.answered_by
|| target._actualModel
|| target._requestedModel
);
const hasEndpointRoute = Boolean(
event.requested_endpoint_id
|| event.selected_endpoint_id
|| event.endpoint_id
|| event.answered_by_endpoint_id
|| event.requested_endpoint_label
|| event.selected_endpoint_label
|| event.endpoint_label
|| event.answered_by_endpoint_label
|| target._requestedEndpointLabel
);
if (hasEndpointRoute) {
target._requestedEndpointId = (
event.requested_endpoint_id
|| event.selected_endpoint_id
|| target._requestedEndpointId
|| null
);
target._requestedEndpointLabel = (
event.requested_endpoint_label
|| event.selected_endpoint_label
|| target._requestedEndpointLabel
|| 'Selected route'
);
target._actualEndpointId = (
event.endpoint_id
|| event.answered_by_endpoint_id
|| target._actualEndpointId
|| target._requestedEndpointId
|| null
);
target._actualEndpointLabel = (
event.endpoint_label
|| event.answered_by_endpoint_label
|| target._actualEndpointLabel
|| target._requestedEndpointLabel
);
}
return target;
}
/** Copy the active route into the bubble created for the next Agent round. */
export function inheritModelRouteState(holder, roundHolder, target, defaultModel = '') {
if (!target) return null;
const source = roundHolder || holder;
target._requestedModel = source?._requestedModel || defaultModel;
target._actualModel = source?._actualModel || target._requestedModel;
if (source?._requestedEndpointLabel || source?._actualEndpointLabel) {
target._requestedEndpointId = source?._requestedEndpointId || null;
target._requestedEndpointLabel = source?._requestedEndpointLabel || 'Selected route';
target._actualEndpointId = source?._actualEndpointId || target._requestedEndpointId;
target._actualEndpointLabel = source?._actualEndpointLabel || target._requestedEndpointLabel;
}
return target;
}
/** Apply final/metrics provenance to the active round, not the first bubble. */
export function applyModelMetricsState(metrics, holder, roundHolder, defaultModel = '') {
const target = roundHolder || holder;
if (!target || !metrics) return target || null;
const roundModels = Array.isArray(metrics.round_models) ? metrics.round_models : [];
const roundModel = roundHolder && roundModels.length
? roundModels[roundModels.length - 1]
: null;
target._requestedModel = metrics.requested_model || target._requestedModel || defaultModel;
target._actualModel = roundModel || metrics.model || target._actualModel || target._requestedModel;
const roundEndpointIds = Array.isArray(metrics.round_endpoint_ids) ? metrics.round_endpoint_ids : [];
const roundEndpointLabels = Array.isArray(metrics.round_endpoint_labels) ? metrics.round_endpoint_labels : [];
if (
metrics.requested_endpoint_label
|| metrics.endpoint_label
|| roundEndpointLabels.length
|| target._requestedEndpointLabel
) {
target._requestedEndpointId = metrics.requested_endpoint_id || target._requestedEndpointId || null;
target._requestedEndpointLabel = metrics.requested_endpoint_label || target._requestedEndpointLabel || 'Selected route';
const hasRoundEndpointId = Boolean(roundHolder && roundEndpointIds.length);
const hasRoundEndpointLabel = Boolean(roundHolder && roundEndpointLabels.length);
target._actualEndpointId = hasRoundEndpointId
? roundEndpointIds[roundEndpointIds.length - 1]
: (metrics.endpoint_id || target._actualEndpointId || target._requestedEndpointId);
target._actualEndpointLabel = hasRoundEndpointLabel
? roundEndpointLabels[roundEndpointLabels.length - 1]
: (metrics.endpoint_label || target._actualEndpointLabel || target._requestedEndpointLabel);
}
return target;
}
+57 -312
View File
@@ -9,9 +9,7 @@ import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js';
import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js';
import { loadPanel } from './panels.js';
import { matchModelKey } from './model/matchKey.js';
import { getTools } from './appConfig.js';
const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
@@ -447,12 +445,8 @@ function stripExecutedFence(match, tag, inline, body) {
async function loadExecFenceRegex() {
try {
// Shared with admin.js, and — more to the point — with the other copies of
// this module: chatRenderer.js is imported under three different ?v= query
// strings, so it is instantiated three times per load and used to issue
// three identical /api/tools requests. appConfig.js is imported by one
// specifier from all of them, so they now share a single fetch.
const data = await getTools();
const res = await fetch('/api/tools', { credentials: 'same-origin' });
const data = await res.json();
const tags = (data.tools || [])
.map((t) => t.id)
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
@@ -621,36 +615,10 @@ export function sameModelName(left, right) {
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
}
function shortEndpointLabel(label) {
const value = modelValue(label);
if (!value) return '';
return value.length > 18 ? value.slice(0, 17) + '…' : value;
}
export function modelRouteLabel(
requestedModel,
actualModel,
requestedEndpointLabel = '',
actualEndpointLabel = '',
requestedEndpointId = '',
actualEndpointId = '',
) {
export function modelRouteLabel(requestedModel, actualModel) {
const requested = modelValue(requestedModel);
const actual = modelValue(actualModel) || requested;
const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel);
const actualRoute = modelValue(actualEndpointId || actualEndpointLabel);
const routeChanged = Boolean(
actualRoute
&& requestedRoute
&& actualRoute !== requestedRoute
);
if (!requested || sameModelName(requested, actual)) {
const model = shortModel(actual || requested);
if (!routeChanged) return model;
const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route');
const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId);
return model + ' (' + from + ' -> ' + to + ')';
}
if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested);
return shortModel(requested) + ' -> ' + shortModel(actual);
}
@@ -661,24 +629,10 @@ export function replyModelPair(modelName, metadata) {
if (actualFromMeta || requestedFromMeta) {
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
const requested = requestedFromMeta || actual;
return {
requestedModel: requested,
actualModel: actual,
requestedEndpointId: meta.requested_endpoint_id || null,
requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route',
actualEndpointId: meta.endpoint_id || null,
actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route',
};
return { requestedModel: requested, actualModel: actual };
}
const fallback = modelValue(modelName);
return {
requestedModel: fallback,
actualModel: fallback,
requestedEndpointId: null,
requestedEndpointLabel: 'Selected route',
actualEndpointId: null,
actualEndpointLabel: 'Selected route',
};
return { requestedModel: fallback, actualModel: fallback };
}
/**
@@ -870,50 +824,12 @@ export function isCostTrackedEndpoint(url) {
}
/** Cost for the current turn, returning null for non-billable endpoints. */
function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) {
// Foreground fallback can answer on a different endpoint than the session's
// selected route. Prefer the backend's non-secret actual-route
// classification; retain the selected-endpoint check for older history.
if (endpointCostTracked === false) return null;
const selectedUrl = selectedEndpointUrl === undefined
? _currentEndpointUrl()
: selectedEndpointUrl;
if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) {
return null;
}
function _billableCost(model, inputTokens, outputTokens) {
const url = _currentEndpointUrl();
if (!isCostTrackedEndpoint(url)) return null;
return getModelCost(model, inputTokens, outputTokens);
}
/** Sum cost using the route/model that produced each Agent round. */
function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) {
const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : [];
if (!buckets.length) {
return _billableCost(
model,
inputTokens,
outputTokens,
metrics.endpoint_cost_tracked,
selectedEndpointUrl,
);
}
let total = 0;
let hasPricedUsage = false;
for (const bucket of buckets) {
if (!bucket || typeof bucket !== 'object') continue;
const bucketCost = _billableCost(
bucket.model || model,
Number(bucket.input_tokens) || 0,
Number(bucket.output_tokens) || 0,
bucket.endpoint_cost_tracked,
selectedEndpointUrl,
);
if (bucketCost === null) continue;
total += bucketCost;
hasPricedUsage = true;
}
return hasPricedUsage ? total : null;
}
export function getImageCost(model, quality, size) {
if (!model) return null;
const m = model.toLowerCase();
@@ -928,9 +844,6 @@ export function getImageCost(model, quality, size) {
/* ── Session cost helpers ─────────────────────────────────────────── */
const _COST_KEY = 'ody-session-cost';
const _COST_RUNS_KEY = 'ody-session-cost-runs';
const _MAX_COST_RUNS_PER_SESSION = 256;
const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger';
/** Return the accumulated cost for the current (or given) session. */
export function getSessionCost(sessionId) {
@@ -938,14 +851,7 @@ export function getSessionCost(sessionId) {
if (!sid) return 0;
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
? Object.values(runCosts[sid])
: [];
return (costs[sid] || 0) + recordedRuns.reduce(
(total, value) => total + (Number(value) || 0),
0,
);
return costs[sid] || 0;
} catch (_e) { return 0; }
}
@@ -957,9 +863,6 @@ export function resetSessionCost(sessionId) {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
delete costs[sid];
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
delete runCosts[sid];
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
} catch (_e) { /* ignore */ }
updateSessionCostUI();
}
@@ -968,8 +871,21 @@ export function resetSessionCost(sessionId) {
export function updateSessionCostUI() {
const el = document.getElementById('session-cost-display');
if (!el) return;
// The ledger records billable work already performed in this session. A
// selected local endpoint does not erase cost from a paid fallback route.
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
// cloud-rate calculation may have left in localStorage for this session.
const _url = _currentEndpointUrl();
if (!isCostTrackedEndpoint(_url)) {
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (sid && getSessionCost(sid) > 0) {
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
delete costs[sid];
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
} catch (_e) { /* ignore */ }
}
el.style.display = 'none';
return;
}
const cost = getSessionCost();
if (cost > 0) {
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
@@ -979,94 +895,6 @@ export function updateSessionCostUI() {
}
}
/** Record one metrics payload in a session ledger at most once. */
export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) {
if (!metrics || typeof metrics !== 'object') return null;
const cost = _metricsBillableCost(
metrics,
metrics.model || 'Unknown',
metrics.input_tokens || 0,
metrics.output_tokens || 0,
selectedEndpointUrl,
);
if (metrics._fromHistory) return cost;
const sid = sessionId || (
window.sessionModule && window.sessionModule.getCurrentSessionId()
);
if (!sid || cost === null) return cost;
const runId = typeof metrics._costRecordId === 'string'
? metrics._costRecordId.trim()
: '';
if ((metrics._costRecorded || metrics._costRecordPending) && !runId) return cost;
// Recorded is only set once the write actually runs; pending covers the
// window while the write waits on the cross-tab lock, so a replay in that
// window cannot double-add and a tab closed mid-queue never claims recorded.
metrics._costRecordPending = true;
const writeCost = () => {
if (runId) {
try {
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
? runCosts[sid]
: {};
// Assigning by detached-run identity is replay-idempotent even when a
// refresh produces a fresh metrics object. The Web Lock around this
// read/modify/write also keeps distinct runs from two tabs from
// overwriting one another's stale snapshot.
sessionRuns[runId] = cost;
const entries = Object.entries(sessionRuns);
if (entries.length > _MAX_COST_RUNS_PER_SESSION) {
const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION);
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
costs[sid] = (costs[sid] || 0) + overflow.reduce(
(total, entry) => total + (Number(entry[1]) || 0),
0,
);
overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]);
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
}
runCosts[sid] = sessionRuns;
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
} catch (_e) { /* ignore */ }
} else {
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
costs[sid] = (costs[sid] || 0) + cost;
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
} catch (_e) { /* ignore */ }
}
metrics._costRecorded = true;
metrics._costRecordPending = false;
const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (currentSid === sid) updateSessionCostUI();
};
let writeStarted = false;
const guardedWrite = () => {
writeStarted = true;
writeCost();
};
try {
if (
typeof navigator !== 'undefined'
&& navigator.locks
&& typeof navigator.locks.request === 'function'
) {
const pendingWrite = navigator.locks.request(_COST_LEDGER_LOCK, guardedWrite);
if (pendingWrite && typeof pendingWrite.catch === 'function') {
pendingWrite.catch(() => {
if (!writeStarted) guardedWrite();
});
}
} else {
guardedWrite();
}
} catch (_e) {
if (!writeStarted) guardedWrite();
}
return cost;
}
/** Create a timestamp span for role labels.
* Pass an ISO string / Date / epoch-ms to render the message's own time
* (used when replaying history). Falls back to "now" when no value is given. */
@@ -1373,7 +1201,7 @@ document.addEventListener('click', function(e) {
} catch {}
});
} else if (kind === 'document') {
import('./document.js?v=20260815approvalsave1').then(mod => {
import('./document.js?v=20260722emailfastindex1').then(mod => {
const open = mod.loadDocument
|| mod.openDocument
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
@@ -1395,7 +1223,7 @@ document.addEventListener('click', function(e) {
if (open) open(id);
}).catch(() => {});
} else if (kind === 'email') {
import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (open) open({ uid: id });
}).catch(() => {});
@@ -1554,7 +1382,7 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
try {
const [galleryMod, editorMod] = await Promise.all([
import('./gallery.js'),
loadPanel('editor'),
import('./galleryEditor.js'),
]);
// Ensure the Gallery modal is open so the editor has a container
// to render into; switch its tabs to the Edit tab.
@@ -2046,19 +1874,23 @@ export function displayMetrics(messageElement, metrics) {
const isReal = metrics.usage_source === 'real';
const ctxPct = metrics.context_percent;
const model = metrics.model || 'Unknown';
const cost = _metricsBillableCost(
metrics,
model,
inputTokens,
outputTokens,
);
const cost = _billableCost(model, inputTokens, outputTokens);
// Nothing useful to show — bail out (only if ALL metrics are missing)
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
// Rendering can occur when metrics arrive and again after [DONE]. The
// ledger mutation is idempotent for that shared payload.
recordSessionMetricsCost(metrics);
// Accumulate session cost (only on fresh metrics, not history reload)
if (!metrics._fromHistory) {
const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (_sid && cost !== null) {
try {
const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
_costs[_sid] = (_costs[_sid] || 0) + cost;
localStorage.setItem(_COST_KEY, JSON.stringify(_costs));
} catch (_e) { /* ignore */ }
updateSessionCostUI();
}
}
// Keep token counts in the Message Stats popup; the footer should stay slim.
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
@@ -2348,7 +2180,6 @@ export function renderAskUserCard(payload, options) {
card.setAttribute('role', 'group');
card.tabIndex = -1;
const multi = !!aq.multi;
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
const head = document.createElement('div');
@@ -2373,27 +2204,6 @@ export function renderAskUserCard(payload, options) {
card.appendChild(question);
card.setAttribute('aria-labelledby', question.id);
if (isToolApproval && aq.action) {
const action = document.createElement('div');
action.className = 'ask-user-option-desc';
const effects = Array.isArray(aq.action.effects)
? aq.action.effects.join(', ')
: '';
action.textContent = [
aq.action.tool || 'tool',
aq.action.content || '',
effects ? `Effects: ${effects}` : '',
aq.action.workspace ? `Workspace: ${aq.action.workspace}` : '',
aq.action.document_id ? `Document: ${aq.action.document_id}` : '',
aq.action.document_version != null
? `Document version: ${aq.action.document_version}`
: '',
aq.action.digest ? `Approval fingerprint: ${aq.action.digest}` : '',
].filter(Boolean).join('\n');
action.style.whiteSpace = 'pre-wrap';
card.appendChild(action);
}
const list = document.createElement('div');
list.className = 'ask-user-options';
card.appendChild(list);
@@ -2431,23 +2241,7 @@ export function renderAskUserCard(payload, options) {
}
if (!multi) {
row.type = 'button';
row.addEventListener('click', () => {
if (isToolApproval) {
card.remove();
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', {
detail: {
approval_id: aq.approval_id,
decision: String((opt && opt.value) || '').toLowerCase(),
label,
document_id: aq.action && aq.action.document_id
? String(aq.action.document_id)
: '',
},
}));
} else {
send(label);
}
});
row.addEventListener('click', () => send(label));
}
list.appendChild(row);
});
@@ -2483,7 +2277,7 @@ export function renderAskUserCard(payload, options) {
});
other.appendChild(otherInput);
other.appendChild(otherSend);
if (!isToolApproval) card.appendChild(other);
card.appendChild(other);
chatBox.appendChild(card);
if (renderOptions.scroll !== false) {
@@ -2513,19 +2307,9 @@ export function addMessage(role, content, modelName, metadata) {
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
// --- Agent multi-bubble reconstruction from saved metadata ---
if (
role === 'assistant'
&& metadata
&& (
(Array.isArray(metadata.tool_events) && metadata.tool_events.length > 0)
|| (Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1)
)
) {
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
const roundTexts = metadata.round_texts || [];
const roundModels = metadata.round_models || [];
const roundEndpointIds = metadata.round_endpoint_ids || [];
const roundEndpointLabels = metadata.round_endpoint_labels || [];
const toolEvents = metadata.tool_events || [];
const toolEvents = metadata.tool_events;
let pendingAskUser = null;
let lastWrap = null;
let firstMsgAi = null;
@@ -2533,20 +2317,16 @@ export function addMessage(role, content, modelName, metadata) {
const toolsByRound = {};
for (const ev of toolEvents) {
const r = ev.round ?? 1;
const r = ev.round || 1;
if (!toolsByRound[r]) toolsByRound[r] = [];
toolsByRound[r].push(ev);
}
const toolRounds = Object.keys(toolsByRound).map(Number);
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length);
const firstRound = (toolsByRound[0] || []).length ? 0 : 1;
for (let roundNum = firstRound; roundNum <= maxRound; roundNum++) {
const r = roundNum - 1;
const txt = r >= 0
? resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata)
: '';
for (let r = 0; r < maxRound; r++) {
const roundNum = r + 1;
const txt = resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata);
if (txt) {
const wrap = document.createElement('div');
@@ -2554,31 +2334,10 @@ export function addMessage(role, content, modelName, metadata) {
const roleEl = document.createElement('div');
roleEl.className = 'role';
const pair = replyModelPair(modelName, metadata);
const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;
const contEndpointId = r < roundEndpointIds.length
? roundEndpointIds[r]
: pair.actualEndpointId;
const contEndpointLabel = r < roundEndpointLabels.length
? roundEndpointLabels[r]
: pair.actualEndpointLabel;
roleEl.textContent = modelRouteLabel(
pair.requestedModel,
contModel,
pair.requestedEndpointLabel,
contEndpointLabel,
pair.requestedEndpointId,
contEndpointId,
);
if (
pair.requestedModel
&& contModel
&& (
!sameModelName(pair.requestedModel, contModel)
|| (pair.requestedEndpointId && contEndpointId && pair.requestedEndpointId !== contEndpointId)
)
) {
roleEl.title = pair.requestedModel + ' -> ' + contModel
+ ' (' + pair.requestedEndpointLabel + ' -> ' + contEndpointLabel + ')';
const contModel = pair.actualModel || pair.requestedModel;
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
roleEl.title = pair.requestedModel + ' -> ' + contModel;
}
applyModelColor(roleEl, contModel);
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
@@ -2733,14 +2492,7 @@ export function addMessage(role, content, modelName, metadata) {
const isCompacted = metadata?.compacted;
const replyModels = replyModelPair(modelName, metadata);
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(
replyModels.requestedModel,
resolvedModel,
replyModels.requestedEndpointLabel,
replyModels.actualEndpointLabel,
replyModels.requestedEndpointId,
replyModels.actualEndpointId,
);
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel);
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
_roleText += ' (Research)';
}
@@ -2751,14 +2503,8 @@ export function addMessage(role, content, modelName, metadata) {
}
r.textContent = _roleText;
if (role !== 'user') {
const endpointChanged = Boolean(
replyModels.requestedEndpointId
&& replyModels.actualEndpointId
&& replyModels.requestedEndpointId !== replyModels.actualEndpointId
);
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) {
r.title = replyModels.requestedModel + ' -> ' + resolvedModel
+ ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')';
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
}
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
r.appendChild(roleTimestamp(metadata?.timestamp));
@@ -3042,7 +2788,6 @@ const chatRenderer = {
getSessionCost,
resetSessionCost,
updateSessionCostUI,
recordSessionMetricsCost,
roleTimestamp,
stripToolBlocks,
copyMessageText,
+3 -3
View File
@@ -7,7 +7,7 @@ import Storage from './storage.js';
import themeModule from './theme.js';
import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
import documentModule from './document.js?v=20260815approvalsave1';
import documentModule from './document.js?v=20260722emailfastindex1';
/**
* Handle a ui_control SSE event — AI-driven UI manipulation.
@@ -156,7 +156,7 @@ export function handleUIControl(uiData) {
if (fn) fn();
}).catch(function(){});
} else if (panel === 'email') {
import('./emailLibrary.js?v=20260815approvalsave1').then(function(mod) {
import('./emailLibrary.js?v=20260722emailfastindex1').then(function(mod) {
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (fn) fn();
}).catch(function(){});
@@ -205,7 +205,7 @@ export function handleUIControl(uiData) {
} catch (e) {
console.warn('open_email_reply existing draft update failed:', e);
}
import('./emailInbox.js?v=20260815approvalsave1').then(function(mod) {
import('./emailInbox.js?v=20260722emailfastindex1').then(function(mod) {
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
}).catch(function(e) {
-23
View File
@@ -1,23 +0,0 @@
/** Build a terminal stream error while preserving provider-supplied text. */
export function createTerminalStreamError(payload = {}) {
const rawError = payload.error;
const message = (
payload.text
|| (typeof rawError === 'string' ? rawError : rawError?.message)
|| `Error ${payload.status || 'unknown'}`
);
const error = new Error(message);
error.name = 'TerminalStreamError';
error.terminalStreamError = true;
error.status = payload.status;
return error;
}
/** Only connection-class stream failures are safe to resubmit automatically. */
export function isRecoverableStreamError(error) {
if (!error || error.terminalStreamError || error.name === 'TerminalStreamError') return false;
if (error.name === 'TypeError') return true;
const message = (error.message || '').toLowerCase();
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(message)) return false;
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(message);
}
+5 -7
View File
@@ -3934,7 +3934,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
leadingIcon: 'check',
action: 'View Message',
onAction: () => {
import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (open) open({
account_id: data.account_id || activeAccountId || null,
@@ -9401,9 +9401,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
/** Save manual edits */
export async function saveDocument({ silent = false, forceVersion = false } = {}) {
if (!activeDocId) return false;
if (!activeDocId) return;
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return false;
if (!textarea) return;
const savingDocId = activeDocId;
saveCurrentToMap();
const localDoc = docs.get(savingDocId);
@@ -9422,7 +9422,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
});
if (res.status === 404) {
if (silent && localDoc?.language === 'email') {
return false;
return;
}
// Streaming/empty email drafts can leave a local tab pointing at a temp
// or already-deleted document. Do not keep surfacing autosave errors for
@@ -9434,7 +9434,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
_syncDocIndicator();
if (!silent && uiModule) uiModule.showError('Document no longer exists');
return false;
return;
}
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
const doc = await res.json();
@@ -9447,7 +9447,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
_syncDocIndicator();
if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved');
return true;
} catch (e) {
console.error('Failed to save document:', e);
const now = Date.now();
@@ -9455,7 +9454,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document');
_lastAutoSaveErrorAt = now;
}
return false;
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
import spinnerModule from './spinner.js';
import sessionModule from './sessions.js';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260815approvalsave1';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260722emailfastindex1';
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
+4 -4
View File
@@ -5,7 +5,7 @@
import spinnerModule from './spinner.js';
import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260815approvalsave1';
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1';
import settingsModule from './settings.js';
import * as Modals from './modalManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
@@ -23,7 +23,6 @@ import {
_tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON,
} from './emailLibrary/signatureFold.js';
import { state } from './emailLibrary/state.js';
import { getSettings } from './appConfig.js';
import { collapseSidebarToRail } from './modalSnap.js';
import { emailApiUrl } from './emailShared.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -994,7 +993,8 @@ function _syncEmailReminderBellVisibility(enabled) {
async function _loadEmailReminderBellVisibility() {
try {
const settings = await getSettings();
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await res.json();
_syncEmailReminderBellVisibility(settings.reminder_channel === 'email');
} catch (_) {
_syncEmailReminderBellVisibility(false);
@@ -6680,7 +6680,7 @@ function _wireAttachmentHandlers(reader, folder) {
ownerModal.classList.add('hidden');
}
}
const docMod = await import('./document.js?v=20260815approvalsave1');
const docMod = await import('./document.js?v=20260722emailfastindex1');
const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
if (typeof load === 'function') {
await load(json.doc_id);
+1 -49
View File
@@ -3,7 +3,7 @@
*/
import uiModule from './ui.js';
import { loadPanel } from './panels.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -15,54 +15,6 @@ const API_BASE = window.location.origin;
let _open = false;
let _galleryResizeHandler = null;
// ── Image editor, loaded on first use ──
// galleryEditor.js plus everything under js/editor/ is 54 modules / 576 KB.
// It used to be a static import here, so every page load paid for it even
// though most sessions never touch the Edit tab. The wrappers below keep the
// three call shapes the rest of this file already uses.
//
// closeEditor() and isEditorOpen() stay synchronous on purpose: if the module
// was never loaded there is no edit session to close, and none can be open.
let _editorMod = null;
let _editorLoading = false;
async function _loadEditor() {
_editorLoading = true;
try {
_editorMod = await loadPanel('editor');
return _editorMod;
} finally {
_editorLoading = false;
}
}
async function openEditor(...args) {
let mod = _editorMod;
if (!mod) {
try {
mod = await _loadEditor();
} catch (e) {
// Previously unreachable — a static import either loaded or the whole
// page failed. Now it can fail on its own (offline before the panel was
// ever cached), so say so instead of doing nothing.
console.error('[gallery] image editor failed to load', e);
uiModule?.showError?.('Failed to load the image editor');
return;
}
}
return mod.openEditor(...args);
}
function closeEditor(...args) {
return _editorMod ? _editorMod.closeEditor(...args) : undefined;
}
// True while the module is still in flight as well — the gallery-close paths
// use this to refuse to tear the container down under an edit that is opening.
function isEditorOpen() {
return _editorLoading || (_editorMod ? _editorMod.isEditorOpen() : false);
}
// Auto-refresh gallery when new image is generated
window.addEventListener('gallery-refresh', (e) => {
if (e?.detail?.source === 'chat-upload' && _sort !== 'recent') {
+2 -2
View File
@@ -3,7 +3,6 @@
// ============================================
import { IS_MAC, isAltGrEvent } from './platform.js';
import { getSettings } from './appConfig.js';
const _defaultKeybinds = {
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
@@ -57,7 +56,8 @@ export function initKeyboardShortcuts(modules) {
window._odysseusKeybinds = { ..._defaultKeybinds };
// Load saved keybinds
getSettings()
fetch('/api/auth/settings', { credentials: 'same-origin' })
.then(r => r.json())
.then(s => { if (s.keybinds) window._odysseusKeybinds = { ..._defaultKeybinds, ...s.keybinds }; })
.catch(() => {});
-53
View File
@@ -1,53 +0,0 @@
/**
* Panel loader registry — imports the modules behind a feature panel the
* first time that panel is actually used.
*
* The panels already populate themselves on open (each fetches its own data).
* They were just not *loaded* on demand: every one of them sat on the critical
* path of every page load, opened or not.
*
* A panel only belongs here once it has been checked for import-time side
* effects that something outside the panel depends on at startup. Entries get
* added one panel at a time, not in bulk.
*
* Note the service worker still precaches these modules (PANEL_PRECACHE in
* sw.js) — they are off the critical path, not off the offline manifest.
*/
const LOADERS = {
editor: () => import('./galleryEditor.js'),
};
/**
* Build a memoising loader over a name -> import-thunk map. Exported so the
* behaviour can be tested without pulling a real panel's module graph in.
*/
export function createPanelLoader(loaders) {
const cache = new Map();
return function load(name) {
const cached = cache.get(name);
if (cached) return cached;
const loader = loaders[name];
if (!loader) throw new Error(`loadPanel: unknown panel "${name}"`);
// A failed load (offline, 404, syntax error) is not memoised — caching the
// rejection would leave the panel broken for the rest of the session even
// after the network came back.
const pending = Promise.resolve().then(loader).catch((err) => {
cache.delete(name);
throw err;
});
cache.set(name, pending);
return pending;
};
}
/**
* Load a panel's module, once. Returns the same promise on every call for the
* same panel; throws for a name that is not registered.
*/
export const loadPanel = createPanelLoader(LOADERS);
/** Registered panel names — the registry is the list, not a second copy of it. */
export function panelNames() {
return Object.keys(LOADERS);
}
+5 -9
View File
@@ -4,21 +4,20 @@
* Search settings management — reads active provider from admin settings.
*/
import { getSettings, invalidateSettings } from './appConfig.js';
let API_BASE = '';
let _provider = 'searxng';
let _loaded = false;
// No API base parameter any more: the settings request lives in appConfig.js and
// resolves against the document origin, which is exactly what API_BASE held.
export function init() {
export function init(apiBase) {
API_BASE = apiBase;
// Fetch provider on init so it's ready when chat needs it
_fetchProvider();
}
async function _fetchProvider() {
try {
const s = await getSettings();
const res = await fetch((API_BASE || '') + '/api/auth/settings', { credentials: 'same-origin' });
const s = await res.json();
_provider = s.search_provider || 'searxng';
_loaded = true;
} catch (e) { /* keep default */ }
@@ -40,9 +39,6 @@ export function getProviderLabel() {
/** Re-fetch after admin saves new settings */
export function refresh() {
// Drop the shared snapshot first: the point of this call is to observe the
// settings that were just written, so it must not be served from cache.
invalidateSettings();
_fetchProvider();
}
+1 -1
View File
@@ -3,7 +3,7 @@
import Storage from './storage.js';
import uiModule, { autoResize, styledPrompt } from './ui.js';
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';
import { providerLogo } from './providers.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
import themeModule from './theme.js';
+292 -132
View File
@@ -3,83 +3,144 @@
import uiModule from './ui.js';
import searchModule from './search.js';
import { byId } from './settings/dom.js';
import {
getSettingsRegistryIssues,
isAdminManagedSettingsTab,
} from './settings/registry.js';
import { bindSettingsSearch } from './settings/search.js';
import { bindSettingsSidebar } from './settings/sidebar.js';
import {
activateSettingsPanel,
getActiveSettingsTab,
bindSettingsNavigation,
} from './settings/navigation.js';
import {
bindSettingsDrag,
bindSettingsClose,
bindOpenPromptModalLink,
showSettingsModal,
hideSettingsModal,
} from './settings/lifecycle.js';
import { makeWindowDraggable } from './windowDrag.js';
import { clearDockSide } from './modalSnap.js';
import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
import { invalidateSettings } from './appConfig.js';
let initialized = false;
let modalEl = null;
let _authPolicy = { password_min_length: 8 };
/**
* POST a settings patch, then drop the shared snapshot in appConfig.js.
*
* Every write in this file goes through here so no save path can forget the
* invalidation — a stale settings object served for the rest of the session is
* a worse bug than the duplicate fetches the cache removes. The invalidation is
* in a `finally` because a request that throws on the way back may still have
* been applied server-side.
*
* Reads in this file deliberately stay direct fetches: this panel is the writer
* and edits what it reads, so it must see the authoritative state, not a cache.
*/
async function _postSettings(body) {
try {
return await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} finally {
invalidateSettings();
}
}
const el = byId;
function el(id) { return document.getElementById(id); }
function esc(s) { return uiModule.esc(s); }
function safeRasterDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
/* ── Settings shell coordination ── */
function onSettingsPanelActivated(tab) {
// Appearance keeps its existing transparent preview behavior.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
/* ── Tab switching ── */
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
// AI endpoints are intentionally refreshed only when entering the AI panel.
if (tab === 'ai') refreshAiModelEndpoints();
function initTabs() {
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.settingsTab;
// Lazy-init admin when first clicking an admin tab
if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return;
}
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
// Mark when the Appearance tab is open so the modal can go
// semi-transparent — lets the user see the rest of the UI react as
// they flip toggles instead of having to close + reopen the modal.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
if (tab === 'ai') refreshAiModelEndpoints();
});
});
}
function openAdminSettingsTab(tab) {
if (window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return true;
/* ── Dragging ── */
function initDrag() {
const header = modalEl.querySelector('.modal-header');
const content = modalEl.querySelector('.settings-modal-content');
if (!header || !content) return;
// Skip interactive controls in the header (e.g. the opacity slider) so
// grabbing them doesn't start a window-drag.
makeWindowDraggable(modalEl, {
content,
header,
skipSelector: 'button, input, select, .theme-opacity-wrap',
enableDock: true,
});
}
function resetWindowPlacement() {
const content = modalEl && modalEl.querySelector('.settings-modal-content');
if (!content) return;
const hadLeft = modalEl.classList.contains('modal-left-docked');
const hadRight = modalEl.classList.contains('modal-right-docked');
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
if (hadLeft) clearDockSide('left', modalEl);
if (hadRight) clearDockSide('right', modalEl);
if (content._leftDockNavObs) {
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
}
return false;
delete content._preDockSnapshot;
delete content._dockSide;
delete content._dockSuspended;
delete content.dataset._tilePreSnap;
delete content.dataset._tileZone;
[
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
].forEach(prop => content.style.removeProperty(prop));
}
/* ── Delegated link: close Settings + open the Prompt (characters) modal ── */
function initOpenPromptModalLink() {
document.addEventListener('click', async (e) => {
const link = e.target.closest('[data-open-prompt-modal]');
if (!link) return;
e.preventDefault();
// Close settings first so the prompt modal isn't stacked on top.
if (modalEl && !modalEl.classList.contains('hidden')) close();
try {
const m = await import('./presets.js');
const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal);
if (typeof fn === 'function') fn();
} catch (_) {
const modal = document.getElementById('custom-preset-modal');
if (modal) modal.classList.remove('hidden');
}
// Force the Persona tab (data-chartab="character") since the link's
// whole purpose is editing personas — not landing on Inject by default.
const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]');
if (personaTab) personaTab.click();
});
}
/* ── Close on backdrop / X ── */
function initClose() {
modalEl.querySelector('.close-btn').addEventListener('click', close);
modalEl.addEventListener('mousedown', e => {
if (uiModule.isTouchInsideModal()) return;
if (e.target === modalEl) close();
});
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return;
// Bail when a transient popover inside the modal is open — Esc should
// dismiss just that, not the whole modal. Same-document listeners fire
// in registration order regardless of capture/bubble, so the popover's
// own handler can't pre-empt ours; we have to opt out here.
const popoverOpen = modalEl.querySelector(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
);
if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) {
return;
}
// If an integration edit/add form is open inside the modal, close
// just that — don't dismiss the whole settings modal. (Pressing
// ESC mid-edit and losing the modal was a fast-typing footgun.)
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
e.preventDefault();
e.stopPropagation();
innerForm.style.display = 'none';
innerForm.innerHTML = '';
return;
}
e.preventDefault();
e.stopPropagation();
close();
});
}
/* ── Appearance-tab opacity slider ──
@@ -302,7 +363,10 @@ function _bindFallbackWidget(opts) {
var body = {};
body[settingKey] = clean;
try {
await _postSettings(body);
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
}
@@ -381,7 +445,14 @@ async function initDefaultChat() {
var epSel = el('set-defaultEpSelect');
var modelSel = el('set-defaultModelSelect');
var msg = el('set-defaultChatMsg');
var fbContainer = el('set-defaultFallbacks');
var addFbBtn = el('set-defaultAddFallback');
var _endpoints = [];
var _fallbacks = []; // Hidden legacy DOM hook; stored values are not loaded or saved.
function enabledEndpoints() {
return _endpoints.filter(function(e) { return e.is_enabled; });
}
// Fill any <select> with the models for a given endpoint id.
function fillModels(selectEl, epId, selected) {
@@ -398,6 +469,64 @@ async function initDefaultChat() {
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
renderFallbacks();
}
// Render the fallback chain. Each row is endpoint + model + remove.
function renderFallbacks() {
fbContainer.innerHTML = '';
_fallbacks.forEach(function(fb, idx) {
var row = document.createElement('div');
row.className = 'settings-fallback-row';
var num = document.createElement('span');
num.className = 'settings-fallback-num';
num.textContent = (idx + 1) + '.';
var epS = document.createElement('select');
epS.className = 'settings-select';
enabledEndpoints().forEach(function(ep) {
var o = document.createElement('option');
o.value = ep.id;
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
epS.appendChild(o);
});
var first = enabledEndpoints()[0];
epS.value = fb.endpoint_id || (first ? first.id : '');
var mS = document.createElement('select');
mS.className = 'settings-select';
fillModels(mS, epS.value, fb.model);
// Keep the model in sync with the values actually shown.
fb.endpoint_id = epS.value;
fb.model = mS.value;
epS.addEventListener('change', function() {
fb.endpoint_id = epS.value;
fillModels(mS, epS.value, '');
fb.model = mS.value;
saveDefault();
});
mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'settings-fallback-remove';
rm.title = 'Remove fallback';
rm.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
rm.addEventListener('click', function() {
_fallbacks.splice(idx, 1);
renderFallbacks();
saveDefault();
});
row.appendChild(num);
row.appendChild(epS);
row.appendChild(mS);
row.appendChild(rm);
fbContainer.appendChild(row);
});
}
try {
@@ -405,6 +534,7 @@ async function initDefaultChat() {
var settings = await res.json();
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
refreshModels(settings.default_model || '');
renderFallbacks();
} catch (e) { console.warn('Failed to load default chat settings', e); }
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
@@ -412,15 +542,25 @@ async function initDefaultChat() {
async function saveDefault() {
try {
await _postSettings({
default_endpoint_id: epSel.value,
default_model: modelSel.value
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
default_endpoint_id: epSel.value,
default_model: modelSel.value
})
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
if (addFbBtn) addFbBtn.addEventListener('click', function() {
var first = enabledEndpoints()[0];
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
renderFallbacks();
saveDefault();
});
_registerAiEndpointRefresh(function(endpoints) {
_endpoints = endpoints;
refreshEndpointOptions(epSel.value, modelSel.value);
@@ -469,9 +609,12 @@ async function initUtilityModel() {
// no toggle, "—" means "unset, use chat").
async function saveUtility() {
try {
await _postSettings({
utility_endpoint_id: epSel.value || '',
utility_model: modelSel.value || ''
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
utility_endpoint_id: epSel.value || '',
utility_model: modelSel.value || ''
})
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 1500);
@@ -564,7 +707,10 @@ async function initTeacherModel() {
spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value;
}
var enabled = enabledToggle ? !!enabledToggle.checked : false;
await _postSettings({ teacher_enabled: enabled, teacher_model: spec });
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teacher_enabled: enabled, teacher_model: spec })
});
msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled';
msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
@@ -639,7 +785,8 @@ async function initImageSettings() {
async function saveSettings() {
try {
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
const res = await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }) });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -713,7 +860,8 @@ async function initVisionSettings() {
async function saveSettings() {
try {
await _postSettings({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value });
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value }) });
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
@@ -794,7 +942,8 @@ async function initTtsSettings() {
async function saveTTS() {
try {
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' }) });
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
@@ -955,7 +1104,9 @@ async function initSttSettings() {
async function saveSTT() {
try {
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() }) });
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
// Notify voiceRecorder of effective provider and update send button icon
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
@@ -1111,7 +1262,10 @@ async function initSearchSettings() {
payload[kf] = keyInput.value.trim();
_settings[kf] = keyInput.value.trim();
}
await _postSettings(payload);
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(refreshStatus, 2000);
if (searchModule && searchModule.refresh) searchModule.refresh();
@@ -1263,7 +1417,11 @@ async function initSearchSettings() {
async function _saveFallbackChain(chain) {
_settings.search_fallback_chain = chain;
try {
await _postSettings({ search_fallback_chain: chain });
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ search_fallback_chain: chain }),
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(refreshStatus, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1427,7 +1585,10 @@ async function initResearchSettings() {
}
}
try {
await _postSettings(payload);
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(showStatus, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1491,7 +1652,10 @@ async function initResearchSearchSettings() {
async function saveResearchSearch() {
try {
await _postSettings({ research_search_provider: searchSel.value });
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ research_search_provider: searchSel.value })
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1533,7 +1697,10 @@ async function initAgentSettings() {
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
try {
await _postSettings(payload);
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
@@ -1928,7 +2095,11 @@ async function initShortcuts() {
async function saveKeybinds() {
try {
await _postSettings({ keybinds });
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keybinds }),
});
// Update global keybinds so they take effect immediately
window._odysseusKeybinds = keybinds;
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
@@ -2140,39 +2311,10 @@ function initAccount() {
function initAll() {
modalEl = el('settings-modal');
bindSettingsNavigation(modalEl, {
openAdminTab: openAdminSettingsTab,
onPanelActivated: onSettingsPanelActivated,
});
bindSettingsSearch(modalEl, {
isAdmin: () => !!window._isAdmin,
openPanel(tab) {
const button = modalEl.querySelector(`[data-settings-tab="${tab}"]`);
if (button) button.click();
},
});
bindSettingsSidebar(modalEl);
const registryIssues = getSettingsRegistryIssues(modalEl);
if (registryIssues.length) {
console.warn('Settings registry/DOM mismatch:', registryIssues);
}
bindSettingsDrag(modalEl);
bindSettingsClose(modalEl, {
closeSettings: close,
isTouchInsideModal: () => uiModule.isTouchInsideModal(),
});
bindOpenPromptModalLink({
getModal: () => modalEl,
closeSettings: close,
});
initTabs();
initDrag();
initClose();
initOpenPromptModalLink();
initOpacityToggle();
initialized = true;
initDefaultChat();
@@ -2221,7 +2363,11 @@ async function initReminderSettings() {
pubDebounce = setTimeout(async () => {
try {
const val = pubUrlIn.value.trim().replace(/\/+$/, '');
await _postSettings({ app_public_url: val });
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_public_url: val }),
});
if (pubUrlMsg) {
pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)';
pubUrlMsg.style.color = 'var(--green,#50fa7b)';
@@ -2519,7 +2665,12 @@ async function initReminderSettings() {
async function save(patch) {
try {
await _postSettings(patch);
await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
} catch (e) { console.warn('Failed to save reminder settings', e); }
}
@@ -2667,7 +2818,7 @@ async function initEmailAccountsSettings() {
el('set-email-open-library-settings')?.addEventListener('click', async () => {
try {
const mod = await import('./emailLibrary.js?v=20260815approvalsave1');
const mod = await import('./emailLibrary.js?v=20260722emailfastindex1');
if (typeof mod.openEmailLibrarySettings === 'function') {
await mod.openEmailLibrarySettings();
}
@@ -5583,35 +5734,44 @@ function syncAdminVisibility() {
═══════════════════════════════════════════ */
export function open(tab) {
if (!initialized) initAll();
syncAppearanceCheckboxes();
showSettingsModal(modalEl);
syncAdminVisibility();
if (tab) {
activateSettingsPanel(modalEl, tab);
if (modalEl.classList.contains('hidden')) {
resetWindowPlacement();
}
// Preserve existing panel-specific side effects when Settings is opened
// directly to a tab as well as when the user navigates there.
const activeTab = tab || getActiveSettingsTab(modalEl);
onSettingsPanelActivated(activeTab);
// Auto-init admin data if showing an admin tab.
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
modalEl.classList.remove('hidden');
syncAdminVisibility();
const content = modalEl.querySelector('.settings-modal-content');
if (tab) {
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
}
// Auto-init admin data if showing an admin tab
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
syncAppearanceOpacity(activeTab === 'appearance');
if (activeTab === 'ai') refreshAiModelEndpoints();
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
window.adminModule._initData();
}
}
export function close() {
if (!modalEl) return;
// Always clear the Appearance state so the rest of the app does not remain
// dimmed if Settings is closed while that panel is active.
// Always clear the appearance-tab body class so the rest of the app
// doesn't keep its dimmed state if the modal got closed mid-tab.
document.body.classList.remove('settings-appearance-open');
syncAppearanceOpacity(false);
hideSettingsModal(modalEl);
syncAppearanceOpacity(false); // clear any opacity-slider fade
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
} else {
modalEl.classList.add('hidden');
}
}
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
-7
View File
@@ -1,7 +0,0 @@
// Shared DOM helpers for the Settings modules.
// Keep this module intentionally small so panel modules do not grow their own
// element-lookup conventions as Settings is split out of settings.js.
export function byId(id) {
return document.getElementById(id);
}
-176
View File
@@ -1,176 +0,0 @@
// Settings modal lifecycle primitives.
//
// Panel-specific behavior belongs elsewhere. This module owns only the window
// shell: dragging/docking reset, close semantics, visibility animation, and the
// delegated link that leaves Settings for the Persona editor.
import { makeWindowDraggable } from '../windowDrag.js';
import { clearDockSide } from '../modalSnap.js';
const _dragBound = new WeakSet();
const _closeBound = new WeakSet();
let _promptLinkBound = false;
export function bindSettingsDrag(modalEl) {
if (!modalEl || _dragBound.has(modalEl)) return;
const header = modalEl.querySelector('.modal-header');
const content = modalEl.querySelector('.settings-modal-content');
if (!header || !content) return;
_dragBound.add(modalEl);
makeWindowDraggable(modalEl, {
content,
header,
skipSelector: 'button, input, select, .theme-opacity-wrap',
enableDock: true,
});
}
export function resetSettingsWindowPlacement(modalEl) {
const content = modalEl?.querySelector('.settings-modal-content');
if (!content) return;
const hadLeft = modalEl.classList.contains('modal-left-docked');
const hadRight = modalEl.classList.contains('modal-right-docked');
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
if (hadLeft) clearDockSide('left', modalEl);
if (hadRight) clearDockSide('right', modalEl);
if (content._leftDockNavObs) {
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
}
delete content._preDockSnapshot;
delete content._dockSide;
delete content._dockSuspended;
delete content.dataset._tilePreSnap;
delete content.dataset._tileZone;
[
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
].forEach(property => content.style.removeProperty(property));
}
export function bindOpenPromptModalLink({ getModal, closeSettings } = {}) {
if (_promptLinkBound) return;
_promptLinkBound = true;
document.addEventListener('click', async event => {
const link = event.target?.closest?.('[data-open-prompt-modal]');
if (!link) return;
event.preventDefault();
const settingsModal = typeof getModal === 'function' ? getModal() : null;
if (
settingsModal
&& !settingsModal.classList.contains('hidden')
&& typeof closeSettings === 'function'
) {
closeSettings();
}
try {
const module = await import('../presets.js');
const openPrompt = module.openCustomPresetModal
|| (module.default && module.default.openCustomPresetModal);
if (typeof openPrompt === 'function') openPrompt();
} catch (_) {
const modal = document.getElementById('custom-preset-modal');
if (modal) modal.classList.remove('hidden');
}
const personaTab = document.querySelector(
'#custom-preset-modal .preset-tab[data-chartab="character"]'
);
if (personaTab) personaTab.click();
});
}
export function bindSettingsClose(modalEl, options = {}) {
if (!modalEl || _closeBound.has(modalEl)) return;
_closeBound.add(modalEl);
const closeSettings = options.closeSettings;
const isTouchInsideModal = options.isTouchInsideModal;
const closeButton = modalEl.querySelector('.close-btn');
closeButton?.addEventListener('click', () => {
if (typeof closeSettings === 'function') closeSettings();
});
modalEl.addEventListener('mousedown', event => {
if (typeof isTouchInsideModal === 'function' && isTouchInsideModal()) return;
if (event.target === modalEl && typeof closeSettings === 'function') {
closeSettings();
}
});
document.addEventListener('keydown', event => {
if (event.key !== 'Escape' || modalEl.classList.contains('hidden')) return;
// Esc should dismiss transient popovers before the Settings window.
const popoverOpen = modalEl.querySelector(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
);
if (
popoverOpen
&& popoverOpen.style.display !== 'none'
&& !popoverOpen.classList.contains('hidden')
) {
return;
}
// Integration/account editors are nested flows. Close the editor first so
// an accidental Esc does not discard the entire Settings context.
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
if (
innerForm
&& innerForm.style.display !== 'none'
&& innerForm.children.length > 0
) {
event.preventDefault();
event.stopPropagation();
innerForm.style.display = 'none';
innerForm.innerHTML = '';
return;
}
event.preventDefault();
event.stopPropagation();
if (typeof closeSettings === 'function') closeSettings();
});
}
export function showSettingsModal(modalEl) {
if (!modalEl) return;
if (modalEl.classList.contains('hidden')) {
resetSettingsWindowPlacement(modalEl);
}
modalEl.classList.remove('hidden');
}
export function hideSettingsModal(modalEl) {
if (!modalEl) return;
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => {
if (!modalEl.classList.contains('hidden')) {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}
}, 250);
return;
}
modalEl.classList.add('hidden');
}
-57
View File
@@ -1,57 +0,0 @@
// Settings navigation primitives.
//
// This module owns panel activation and sidebar click routing only. Individual
// panels continue to own their data loading and side effects.
import { DEFAULT_SETTINGS_PANEL_ID, isAdminManagedSettingsTab } from './registry.js';
const _boundModals = new WeakSet();
export function activateSettingsPanel(modalEl, tab) {
if (!modalEl || !tab) return null;
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
button.classList.toggle('active', button.dataset.settingsTab === tab);
});
modalEl.querySelectorAll('[data-settings-panel]').forEach(panel => {
panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tab);
});
return tab;
}
export function getActiveSettingsTab(modalEl, fallback = DEFAULT_SETTINGS_PANEL_ID) {
if (!modalEl) return fallback;
const active = modalEl.querySelector('[data-settings-tab].active');
return active?.dataset?.settingsTab || fallback;
}
export function bindSettingsNavigation(modalEl, options = {}) {
if (!modalEl || _boundModals.has(modalEl)) return;
_boundModals.add(modalEl);
const openAdminTab = options.openAdminTab;
const onPanelActivated = options.onPanelActivated;
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
button.addEventListener('click', () => {
const tab = button.dataset.settingsTab;
if (!tab) return;
// Preserve the existing lazy-admin path: when the admin module accepts
// the tab, it owns activation/rendering and the Settings shell does not
// perform a second local switch.
if (
isAdminManagedSettingsTab(tab)
&& typeof openAdminTab === 'function'
&& openAdminTab(tab, button) === true
) {
return;
}
activateSettingsPanel(modalEl, tab);
if (typeof onPanelActivated === 'function') {
onPanelActivated(tab, button);
}
});
});
}
-237
View File
@@ -1,237 +0,0 @@
// Canonical metadata for the existing Settings information architecture.
//
// This module describes Settings; it does not render the sidebar, load panel
// data, or own panel behavior. Keeping those concerns separate lets the
// current markup remain stable while navigation/search code shares one source
// of truth for panel identity and ownership.
function defineGroup(definition) {
return Object.freeze({ ...definition });
}
function definePanel(definition) {
return Object.freeze({
controller: 'settings',
adminOnly: false,
...definition,
keywords: Object.freeze([...(definition.keywords || [])]),
});
}
export const SETTINGS_GROUPS = Object.freeze([
defineGroup({
id: 'models',
label: 'Models & AI',
}),
defineGroup({
id: 'communications',
label: 'Communications',
}),
defineGroup({
id: 'experience',
label: 'Experience',
}),
defineGroup({
id: 'account',
label: 'Account',
}),
defineGroup({
id: 'administration',
label: 'Administration',
adminOnly: true,
}),
]);
// Order intentionally mirrors the existing Settings sidebar.
export const SETTINGS_PANELS = Object.freeze([
definePanel({
id: 'services',
label: 'Add Models',
group: 'models',
controller: 'admin',
keywords: ['models', 'provider', 'endpoint'],
}),
definePanel({
id: 'added-models',
label: 'Added Models',
group: 'models',
controller: 'admin',
keywords: ['models', 'configured', 'provider', 'endpoint'],
}),
definePanel({
id: 'ai',
label: 'AI Defaults',
group: 'models',
keywords: ['ai', 'defaults', 'model', 'vision', 'image', 'tts', 'stt'],
}),
definePanel({
id: 'search',
label: 'Search',
group: 'models',
keywords: ['search', 'research', 'provider'],
}),
definePanel({
id: 'integrations',
label: 'Integrations',
group: 'communications',
controller: 'admin',
keywords: ['integrations', 'connections', 'services'],
}),
definePanel({
id: 'email',
label: 'Email',
group: 'communications',
keywords: ['email', 'imap', 'smtp', 'oauth'],
}),
definePanel({
id: 'reminders',
label: 'Reminders',
group: 'communications',
keywords: ['reminders', 'notifications', 'alerts'],
}),
definePanel({
id: 'appearance',
label: 'Appearance',
group: 'experience',
keywords: ['appearance', 'theme', 'font', 'density', 'peek'],
}),
definePanel({
id: 'shortcuts',
label: 'Shortcuts',
group: 'experience',
keywords: ['shortcuts', 'keyboard', 'hotkeys'],
}),
definePanel({
id: 'account',
label: 'Account',
group: 'account',
keywords: ['account', 'password', 'logout'],
}),
definePanel({
id: 'tools',
label: 'Agent Tools',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['agent', 'tools'],
}),
definePanel({
id: 'users',
label: 'Users',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['users', 'accounts', 'admin'],
}),
definePanel({
id: 'system',
label: 'System',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['system', 'admin', 'server'],
}),
]);
export const DEFAULT_SETTINGS_PANEL_ID = 'services';
const _panelsById = new Map(
SETTINGS_PANELS.map(panel => [panel.id, panel]),
);
export function getSettingsPanel(id) {
return _panelsById.get(String(id || '')) || null;
}
export function getSettingsPanelsForGroup(groupId) {
return SETTINGS_PANELS.filter(panel => panel.group === groupId);
}
export function isAdminManagedSettingsTab(id) {
return getSettingsPanel(id)?.controller === 'admin';
}
export function isAdminOnlySettingsTab(id) {
return getSettingsPanel(id)?.adminOnly === true;
}
export function getSettingsPanelSearchText(panelOrId) {
const panel = typeof panelOrId === 'string'
? getSettingsPanel(panelOrId)
: panelOrId;
if (!panel) return '';
return [
panel.label,
...(panel.keywords || []),
].join(' ').toLowerCase();
}
function normalizeSettingsSearch(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
export function searchSettingsPanels(query, options = {}) {
const normalized = normalizeSettingsSearch(query);
if (!normalized) return [];
const terms = normalized.split(' ');
const isAdmin = options.isAdmin === true;
return SETTINGS_PANELS.filter(panel => {
if (panel.adminOnly && !isAdmin) return false;
const haystack = getSettingsPanelSearchText(panel);
return terms.every(term => haystack.includes(term));
});
}
export function getSettingsRegistryIssues(modalEl) {
if (!modalEl) return ['Settings modal is unavailable'];
const tabIds = Array.from(
modalEl.querySelectorAll('[data-settings-tab]'),
element => element.dataset.settingsTab,
).filter(Boolean);
const panelIds = Array.from(
modalEl.querySelectorAll('[data-settings-panel]'),
element => element.dataset.settingsPanel,
).filter(Boolean);
const registryIds = SETTINGS_PANELS.map(panel => panel.id);
const issues = [];
const duplicates = ids => ids.filter(
(id, index) => ids.indexOf(id) !== index,
);
for (const id of new Set(duplicates(tabIds))) {
issues.push(`Duplicate Settings tab: ${id}`);
}
for (const id of new Set(duplicates(panelIds))) {
issues.push(`Duplicate Settings panel: ${id}`);
}
for (const id of registryIds) {
if (!tabIds.includes(id)) issues.push(`Registry tab missing from DOM: ${id}`);
if (!panelIds.includes(id)) issues.push(`Registry panel missing from DOM: ${id}`);
}
for (const id of tabIds) {
if (!registryIds.includes(id)) issues.push(`DOM tab missing from registry: ${id}`);
}
for (const id of panelIds) {
if (!registryIds.includes(id)) issues.push(`DOM panel missing from registry: ${id}`);
}
return issues;
}
-172
View File
@@ -1,172 +0,0 @@
import {
SETTINGS_GROUPS,
getSettingsPanel,
searchSettingsPanels,
} from './registry.js';
const _boundModals = new WeakSet();
function groupLabelFor(panel) {
const group = SETTINGS_GROUPS.find(candidate => candidate.id === panel.group);
return group?.label || '';
}
function clearResults(resultsEl) {
if (!resultsEl) return;
resultsEl.replaceChildren();
resultsEl.classList.add('hidden');
}
function getResultButtons(resultsEl) {
if (!resultsEl) return [];
return Array.from(resultsEl.querySelectorAll('[data-settings-search-result]'));
}
function setActiveResult(resultsEl, index) {
const buttons = getResultButtons(resultsEl);
if (!buttons.length) return -1;
let next = index;
if (next < 0) next = buttons.length - 1;
if (next >= buttons.length) next = 0;
buttons.forEach((button, buttonIndex) => {
const active = buttonIndex === next;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
if (typeof buttons[next].scrollIntoView === 'function') {
buttons[next].scrollIntoView({ block: 'nearest' });
}
return next;
}
export function bindSettingsSearch(modalEl, options = {}) {
if (!modalEl || _boundModals.has(modalEl)) return;
const input = modalEl.querySelector('#settings-nav-search');
const resultsEl = modalEl.querySelector('#settings-nav-search-results');
if (!input || !resultsEl) return;
_boundModals.add(modalEl);
const isAdmin = typeof options.isAdmin === 'function'
? options.isAdmin
: () => false;
const openPanel = typeof options.openPanel === 'function'
? options.openPanel
: () => {};
let activeIndex = -1;
function reset() {
input.value = '';
activeIndex = -1;
clearResults(resultsEl);
}
function activateResult(button) {
const panelId = button?.dataset?.settingsSearchResult;
if (!panelId || !getSettingsPanel(panelId)) return;
openPanel(panelId);
reset();
}
function render() {
const query = input.value.trim();
activeIndex = -1;
resultsEl.replaceChildren();
if (!query) {
resultsEl.classList.add('hidden');
return;
}
const matches = searchSettingsPanels(query, {
isAdmin: isAdmin(),
});
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'settings-search-empty';
empty.textContent = 'No settings found';
resultsEl.appendChild(empty);
resultsEl.classList.remove('hidden');
return;
}
for (const panel of matches) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'settings-search-result';
button.setAttribute('data-settings-search-result', panel.id);
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', 'false');
const label = document.createElement('span');
label.className = 'settings-search-result-label';
label.textContent = panel.label;
const group = document.createElement('span');
group.className = 'settings-search-result-group';
group.textContent = groupLabelFor(panel);
button.append(label, group);
button.addEventListener('click', () => activateResult(button));
resultsEl.appendChild(button);
}
resultsEl.classList.remove('hidden');
}
input.addEventListener('input', render);
input.addEventListener('focus', () => {
if (input.value.trim()) render();
});
input.addEventListener('keydown', event => {
const buttons = getResultButtons(resultsEl);
if (event.key === 'Escape') {
if (!input.value && resultsEl.classList.contains('hidden')) return;
event.preventDefault();
event.stopPropagation();
reset();
return;
}
if (!buttons.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
activeIndex = setActiveResult(resultsEl, activeIndex + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
activeIndex = setActiveResult(resultsEl, activeIndex - 1);
return;
}
if (event.key === 'Enter') {
const target = buttons[activeIndex >= 0 ? activeIndex : 0];
if (!target) return;
event.preventDefault();
activateResult(target);
}
});
modalEl.addEventListener('mousedown', event => {
if (event.target === input || resultsEl.contains(event.target)) return;
clearResults(resultsEl);
activeIndex = -1;
});
return { reset, render };
}
-238
View File
@@ -1,238 +0,0 @@
const STORAGE_WIDTH = 'odysseus-settings-sidebar-width';
const STORAGE_COLLAPSED = 'odysseus-settings-sidebar-collapsed';
export const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 220;
export const SETTINGS_SIDEBAR_MIN_WIDTH = 150;
export const SETTINGS_SIDEBAR_MAX_WIDTH = 340;
export const SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD = 110;
const _bound = new WeakSet();
function clampWidth(value) {
const width = Number(value);
if (!Number.isFinite(width)) return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
return Math.max(
SETTINGS_SIDEBAR_MIN_WIDTH,
Math.min(SETTINGS_SIDEBAR_MAX_WIDTH, width),
);
}
function readStoredWidth() {
try {
const stored = localStorage.getItem(STORAGE_WIDTH);
if (stored == null || String(stored).trim() === '') {
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
}
return clampWidth(stored);
} catch {
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
}
}
function readStoredCollapsed() {
try {
return localStorage.getItem(STORAGE_COLLAPSED) === '1';
} catch {
return false;
}
}
function storeWidth(width) {
try {
localStorage.setItem(STORAGE_WIDTH, String(Math.round(width)));
} catch {}
}
function storeCollapsed(collapsed) {
try {
localStorage.setItem(STORAGE_COLLAPSED, collapsed ? '1' : '0');
} catch {}
}
function syncResizeHandleAria(modalEl, width = null) {
const handle = modalEl?.querySelector('#settings-sidebar-resize-handle');
if (!handle) return;
const sidebar = modalEl.querySelector('.settings-sidebar');
const collapsed = sidebar?.classList.contains('settings-sidebar-collapsed');
const current = collapsed
? SETTINGS_SIDEBAR_MIN_WIDTH
: clampWidth(
width ?? sidebar?.getBoundingClientRect?.().width
?? SETTINGS_SIDEBAR_DEFAULT_WIDTH
);
handle.setAttribute('aria-valuemin', String(SETTINGS_SIDEBAR_MIN_WIDTH));
handle.setAttribute('aria-valuemax', String(SETTINGS_SIDEBAR_MAX_WIDTH));
handle.setAttribute('aria-valuenow', String(Math.round(current)));
}
function isDesktopSidebarMode(modalEl) {
const content = modalEl?.querySelector('.settings-modal-content');
if (!content) return false;
// Mirrors the existing container breakpoint where the sidebar becomes a
// horizontal rail. Resizing/collapse applies only to the vertical desktop
// navigation layout.
return content.getBoundingClientRect().width > 620;
}
export function setSettingsSidebarCollapsed(modalEl, collapsed, options = {}) {
const sidebar = modalEl?.querySelector('.settings-sidebar');
if (!sidebar) return false;
const next = collapsed === true;
sidebar.classList.toggle('settings-sidebar-collapsed', next);
const toggle = sidebar.querySelector('#settings-sidebar-toggle');
if (toggle) {
toggle.setAttribute('aria-expanded', next ? 'false' : 'true');
toggle.setAttribute(
'aria-label',
next ? 'Expand settings navigation' : 'Collapse settings navigation',
);
toggle.title = next
? 'Expand settings navigation'
: 'Collapse settings navigation';
}
if (!next) {
const width = clampWidth(options.width ?? readStoredWidth());
sidebar.style.setProperty('--settings-sidebar-width', `${width}px`);
syncResizeHandleAria(modalEl, width);
} else {
syncResizeHandleAria(modalEl);
}
if (options.persist !== false) storeCollapsed(next);
return next;
}
export function setSettingsSidebarWidth(modalEl, width, options = {}) {
const sidebar = modalEl?.querySelector('.settings-sidebar');
if (!sidebar) return null;
const next = clampWidth(width);
sidebar.style.setProperty('--settings-sidebar-width', `${next}px`);
syncResizeHandleAria(modalEl, next);
if (options.persist !== false) storeWidth(next);
return next;
}
export function bindSettingsSidebar(modalEl) {
if (!modalEl || _bound.has(modalEl)) return;
_bound.add(modalEl);
const sidebar = modalEl.querySelector('.settings-sidebar');
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
if (!sidebar || !handle || !toggle) return;
setSettingsSidebarWidth(modalEl, readStoredWidth(), { persist: false });
setSettingsSidebarCollapsed(
modalEl,
readStoredCollapsed(),
{ persist: false },
);
toggle.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (!isDesktopSidebarMode(modalEl)) return;
const collapsed = sidebar.classList.contains('settings-sidebar-collapsed');
setSettingsSidebarCollapsed(modalEl, !collapsed);
});
let startX = 0;
let startWidth = 0;
function stopResize() {
if (!sidebar.classList.contains('settings-sidebar-resizing')) return;
sidebar.classList.remove('settings-sidebar-resizing');
document.body.classList.remove('settings-sidebar-resize-active');
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', stopResize);
const width = sidebar.getBoundingClientRect().width;
if (width < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
setSettingsSidebarCollapsed(modalEl, true);
return;
}
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
setSettingsSidebarWidth(modalEl, width);
storeCollapsed(false);
}
function onPointerMove(event) {
const rawWidth = startWidth + (event.clientX - startX);
if (rawWidth < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
sidebar.style.setProperty(
'--settings-sidebar-width',
`${Math.max(34, rawWidth)}px`,
);
return;
}
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
setSettingsSidebarWidth(modalEl, rawWidth, { persist: false });
}
handle.addEventListener('pointerdown', event => {
if (!isDesktopSidebarMode(modalEl)) return;
event.preventDefault();
startX = event.clientX;
startWidth = sidebar.getBoundingClientRect().width;
sidebar.classList.remove('settings-sidebar-collapsed');
sidebar.classList.add('settings-sidebar-resizing');
document.body.classList.add('settings-sidebar-resize-active');
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', stopResize);
});
handle.addEventListener('keydown', event => {
if (!isDesktopSidebarMode(modalEl)) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle.click();
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
if (sidebar.classList.contains('settings-sidebar-collapsed')) {
setSettingsSidebarCollapsed(modalEl, false);
}
const current = sidebar.getBoundingClientRect().width;
const delta = event.key === 'ArrowLeft' ? -16 : 16;
// Once keyboard resizing reaches the declared minimum, another ArrowLeft
// collapses the rail. Without this explicit boundary transition the width
// setter clamps 134px back to 150px forever, making keyboard collapse via
// ArrowLeft unreachable.
if (
event.key === 'ArrowLeft'
&& current <= SETTINGS_SIDEBAR_MIN_WIDTH
) {
setSettingsSidebarCollapsed(modalEl, true);
return;
}
setSettingsSidebarWidth(modalEl, current + delta);
});
}
-57
View File
@@ -1128,67 +1128,10 @@ function _renderTestLog(logEl, verdictEl, job, card, name) {
else if (ev.type === 'agent_step') add('— round ' + ev.round + ' —', 'skill-test-round');
else if (ev.type === 'tool_start') add('▸ ' + ev.tool + ' ' + String(ev.command || '').slice(0, 200), 'skill-test-tool');
else if (ev.type === 'tool_output') add(String(ev.output || '').slice(0, 500), 'skill-test-out');
else if (ev.type === 'approval_granted' || ev.type === 'approval_denied') add(ev.text || '', 'skill-test-meta');
else if (ev.type === 'say') add(ev.text || '', 'skill-test-say');
else if (ev.type === 'evaluating') add('Evaluating run…', 'skill-test-meta');
else if (ev.type === 'error') add('Error: ' + (ev.error || 'run failed'), 'skill-test-err');
}
if (job.status === 'awaiting_approval' && job.approval) {
const approval = job.approval;
const box = document.createElement('div');
box.className = 'skill-test-approval';
const question = document.createElement('div');
question.className = 'skill-test-meta';
question.textContent = approval.question || 'Allow this exact action once?';
box.appendChild(question);
if (approval.action) {
const action = document.createElement('pre');
action.className = 'skill-test-out';
action.textContent = [
approval.action.tool || 'tool',
approval.action.content || '',
Array.isArray(approval.action.effects)
? `Effects: ${approval.action.effects.join(', ')}`
: '',
approval.action.workspace ? `Workspace: ${approval.action.workspace}` : '',
approval.action.digest ? `Approval fingerprint: ${approval.action.digest}` : '',
].filter(Boolean).join('\n');
box.appendChild(action);
}
const actions = document.createElement('div');
actions.className = 'modal-footer';
const decide = async (decision) => {
actions.querySelectorAll('button').forEach(btn => { btn.disabled = true; });
try {
const response = await fetch(
`${API}/api/skills/${encodeURIComponent(name)}/test-approval`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approval_id: approval.approval_id, decision }),
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await _testSkill(card, name, false);
} catch (error) {
add(`Approval failed: ${error.message || error}`, 'skill-test-err');
actions.querySelectorAll('button').forEach(btn => { btn.disabled = false; });
}
};
for (const [decision, label, cls] of [
['deny', 'Deny', 'confirm-btn confirm-btn-secondary'],
['approve', 'Allow once', 'confirm-btn confirm-btn-primary'],
]) {
const button = document.createElement('button');
button.type = 'button';
button.className = cls;
button.textContent = label;
button.addEventListener('click', () => decide(decision));
actions.appendChild(button);
}
box.appendChild(actions);
logEl.appendChild(box);
}
if (job.status === 'running') add('…running (you can close this — it keeps going)', 'skill-test-meta');
logEl.scrollTop = logEl.scrollHeight;
if (job.status === 'done' && job.verdict) _renderTestVerdict(verdictEl, job.verdict, card, name);
+9 -9
View File
@@ -16,13 +16,12 @@ import modelsModule from './models.js';
import chatRenderer from './chatRenderer.js';
import spinnerModule from './spinner.js';
import themeModule from './theme.js';
import documentModule from './document.js?v=20260815approvalsave1';
import documentModule from './document.js?v=20260722emailfastindex1';
import workspaceModule from './workspace.js';
import settingsModule from './settings.js';
import cookbookModule from './cookbook.js';
import { EVAL_PROMPTS } from './compare/index.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
import { getSettings } from './appConfig.js';
// ── Module state ──────────────────────────────────────────────────────
@@ -2028,12 +2027,12 @@ async function _cmdUsage(args, ctx) {
const messageCount = Number(session?.message_count || 0);
const totalTokens = Number(session?.total_tokens || 0);
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
const cost = chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
const costLine = cost > 0
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
: costTracked
? 'Estimated local cost: unavailable or zero'
: 'Estimated local cost: no billable usage recorded';
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
const costLine = costTracked
? (cost > 0
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
: 'Estimated local cost: unavailable or zero')
: 'Estimated local cost: not tracked for this endpoint';
slashReply(`<pre>${[
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
@@ -5221,7 +5220,8 @@ async function _cmdShortcuts(args, ctx) {
};
try {
const settings = await getSettings();
const res = await fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' });
const settings = await res.json();
if (settings.keybinds) {
keybinds = { ...keybinds, ...settings.keybinds };
}
+18 -19
View File
@@ -10,7 +10,6 @@ import { topPortalZ } from './toolWindowZOrder.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import { getSettings, invalidateSettings } from './appConfig.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -215,31 +214,31 @@ async function _fetchActions() {
return _builtinActions;
}
let _urgentEmailSettings = null;
async function _fetchUrgentEmailSettings() {
if (_urgentEmailSettings) return _urgentEmailSettings;
try {
return await getSettings();
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
_urgentEmailSettings = await res.json();
} catch (e) {
return { urgent_email_prompt: '' };
_urgentEmailSettings = { urgent_email_prompt: '' };
}
return _urgentEmailSettings;
}
async function _saveUrgentEmailSettings(prompt) {
try {
await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
urgent_email_prompt: prompt || '',
}),
});
} finally {
// The shared snapshot still carries the old prompt — drop it so the next
// read (here or in any other module) sees what was just written. In a
// `finally` because a request that throws on the way back may still have
// been applied.
invalidateSettings();
}
_urgentEmailSettings = {
...(_urgentEmailSettings || {}),
urgent_email_prompt: prompt || '',
};
await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
urgent_email_prompt: prompt || '',
}),
});
}
const _EMAIL_ACCOUNT_ACTIONS = new Set([
+3 -6
View File
@@ -1,8 +1,6 @@
// static/js/tts-ai.js
// AI Text-to-Speech Module — supports server TTS and browser Web Speech API
import { getSettings } from './appConfig.js';
class AITTSManager {
constructor() {
this.currentAudio = null;
@@ -32,11 +30,10 @@ class AITTSManager {
async checkAvailability() {
try {
// Check user setting first — if TTS is disabled in settings, don't show buttons.
// settings.js re-calls this right after saving TTS settings; it invalidates
// the shared cache before doing so, so this still sees the new value.
// Check user setting first — if TTS is disabled in settings, don't show buttons
try {
const settings = await getSettings();
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await settingsRes.json();
if (settings.tts_enabled === false) {
this.available = false;
this._provider = 'disabled';
+16 -36
View File
@@ -5,29 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
<title>Odysseus — Login</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="manifest" href="static/manifest.json">
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
(function(){
function computeAppBasePath() {
var path = window.location.pathname || '/login';
if (path === '/login') {
return '';
}
if (path.endsWith('/login')) {
return path.slice(0, -'/login'.length).replace(/\/+$/, '');
}
return '';
}
var appBasePath = computeAppBasePath();
window.__odysseusLoginBasePath = appBasePath;
window.__odysseusLoginAppUrl = function(path) {
var normalizedPath = String(path || '/');
if (!normalizedPath.startsWith('/')) {
normalizedPath = '/' + normalizedPath;
}
return appBasePath + normalizedPath;
};
// Per-theme bg-effect defaults — mirrors THEME_DEFAULT_* maps in
// static/js/theme.js so login picks the same default pattern as the
// main app for users who never explicitly chose one.
@@ -104,8 +85,8 @@
})();
</script>
<style>
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
uncustomized theme — or a fresh browser with no `odysseus-theme` in
localStorage — renders the login page in the same palette as the rest
@@ -319,10 +300,9 @@
<script nonce="{{CSP_NONCE}}">
(async () => {
const appUrl = window.__odysseusLoginAppUrl || ((path) => path);
// Load version
try {
const vr = await fetch(appUrl('/api/version'));
const vr = await fetch('/api/version');
if (vr.ok) {
const vd = await vr.json();
document.getElementById('version-label').textContent = 'v' + vd.version;
@@ -383,12 +363,12 @@
// Check auth status and fetch policy in parallel, but don't block the
// authenticated redirect on the policy response.
const policyPromise = fetch(appUrl('/api/auth/policy'), { credentials: 'same-origin' }).catch(() => null);
const policyPromise = fetch('/api/auth/policy', { credentials: 'same-origin' }).catch(() => null);
try {
const statusRes = await fetch(appUrl('/api/auth/status'), { credentials: 'same-origin' });
const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' });
const data = await statusRes.json();
if (data.authenticated) {
window.location.replace(appUrl('/'));
window.location.replace('/');
return;
}
signupAllowed = !!data.signup_enabled;
@@ -425,7 +405,7 @@
if (!code) { totpInput.focus(); submitBtn.disabled = false; return; }
const remember = document.getElementById('remember').checked;
try {
const res = await fetch(appUrl('/api/auth/login'), {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
@@ -469,7 +449,7 @@
// Setup or signup first
if (mode === 'setup' || mode === 'signup') {
const endpoint = mode === 'setup' ? appUrl('/api/auth/setup') : appUrl('/api/auth/signup');
const endpoint = mode === 'setup' ? '/api/auth/setup' : '/api/auth/signup';
try {
const res = await fetch(endpoint, {
method: 'POST',
@@ -497,19 +477,19 @@
submitBtn.innerHTML = '<span class="login-spinner" aria-hidden="true"></span>';
submitBtn.disabled = true;
Promise.all([
fetch(appUrl('/api/sessions'), { credentials: 'same-origin' }).then(r => r.json()),
fetch(appUrl('/api/auth/features'), { credentials: 'same-origin' }).then(r => r.json()),
fetch(appUrl('/api/auth/settings'), { credentials: 'same-origin' }).then(r => r.json()),
fetch('/api/sessions', { credentials: 'same-origin' }).then(r => r.json()),
fetch('/api/auth/features', { credentials: 'same-origin' }).then(r => r.json()),
fetch('/api/auth/settings', { credentials: 'same-origin' }).then(r => r.json()),
]).then(([sess, feat, sett]) => {
sessionStorage.setItem('ody-prefetch-sessions', JSON.stringify(sess));
sessionStorage.setItem('ody-prefetch-features', JSON.stringify(feat));
sessionStorage.setItem('ody-prefetch-settings', JSON.stringify(sett));
}).catch(() => {}).finally(() => { window.location.replace(appUrl('/')); });
}).catch(() => {}).finally(() => { window.location.replace('/'); });
}
async function doLogin(totpCode) {
const loginBody = { username, password, remember };
if (totpCode) loginBody.totp_code = totpCode;
const res = await fetch(appUrl('/api/auth/login'), {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
@@ -607,7 +587,7 @@ if (window.visualViewport) {
// `applyBgPattern` would never fire on its own. We call it directly
// here against the pattern the bootstrap already chose.
try {
const tm = await import((window.__odysseusLoginAppUrl || ((path) => path))('/static/js/theme.js'));
const tm = await import('/static/js/theme.js');
const pattern = window.__loginBgPattern;
if (pattern && tm.applyBgPattern) tm.applyBgPattern(pattern);
} catch (e) {
+8 -271
View File
@@ -9685,11 +9685,11 @@ body.fullwidth-chat .chat-history {
.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
content: '';
position: absolute;
/* Centre the 6px dot on the rail: the rail sits at left:5px + 2px wide,
so its centre is 6px from the thread's left edge and the dot's left
edge belongs at 3px — i.e. 3px - padding-left. Keep this in step with
.agent-thread-dot and with the mobile padding override below. */
left: -19px;
/* -17px (was -15) nudges the terminating dot 2px further left so it
sits flush with the thread's left rail when the search node is
expanded. This is the "big glow dot at the bottom" the user sees
after a web_search step. */
left: -17px;
bottom: 5px;
width: 6px;
height: 6px;
@@ -9958,11 +9958,6 @@ body.fullwidth-chat .chat-history {
left: -16px;
top: 10px;
}
/* padding-left drops to 18px here, so the terminating dot needs its own
offset (3px - 18px) to stay centred on the rail. */
.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
left: -15px;
}
}
/* ===== AGENT TOOL OUTPUT (inside thread nodes) ===== */
@@ -23605,7 +23600,7 @@ body.gallery-selecting .gallery-dl-btn,
/* ===== Settings Modal Layout ===== */
.settings-modal-content {
width: min(1040px, 94vw);
width: min(720px, 92vw);
max-height: 85vh;
padding: 0;
container-type: inline-size;
@@ -23659,13 +23654,10 @@ body.gallery-selecting .gallery-dl-btn,
}
.settings-sidebar {
position: relative;
width: var(--settings-sidebar-width, 220px);
flex: 0 0 var(--settings-sidebar-width, 220px);
min-width: 0;
width: 160px;
flex-shrink: 0;
border-right: 1px solid var(--border);
padding: 8px;
transition: width 0.16s ease, flex-basis 0.16s ease;
display: flex;
flex-direction: column;
gap: 2px;
@@ -41144,258 +41136,3 @@ body.theme-frosted .modal {
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
.compare-pane { min-height: 60dvh; }
}
/* ── Settings navigation / finder ────────────────────────────── */
.settings-sidebar-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
overflow-y: auto;
overflow-x: hidden;
}
.settings-nav-search-wrap {
position: relative;
margin: 0 0 8px;
}
.settings-nav-search {
box-sizing: border-box;
width: 100%;
min-width: 0;
height: 32px;
padding: 5px 9px 5px 29px;
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
background: var(--bg);
color: var(--fg);
font: inherit;
font-size: 11px;
}
.settings-nav-search:focus {
border-color: var(--accent, var(--red));
}
.settings-nav-search::placeholder {
color: var(--fg);
opacity: 0.45;
}
.settings-nav-search-icon {
position: absolute;
z-index: 2;
top: 9px;
left: 9px;
pointer-events: none;
opacity: 0.5;
}
.settings-nav-search-results {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
min-width: 100%;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
.settings-search-result {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 9px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--fg);
font: inherit;
text-align: left;
cursor: pointer;
}
.settings-search-result:hover,
.settings-search-result.active {
background: color-mix(in srgb, var(--fg) 8%, transparent);
}
.settings-search-result-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.settings-search-result-group {
flex: 0 0 auto;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
opacity: 0.5;
}
.settings-search-empty {
padding: 8px;
color: var(--fg);
font-size: 10px;
text-align: center;
opacity: 0.55;
}
.settings-sidebar-resize-handle {
position: absolute;
z-index: 20;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
background: transparent;
touch-action: none;
}
.settings-sidebar-resize-handle:hover,
.settings-sidebar-resizing .settings-sidebar-resize-handle {
background: var(--accent, var(--red));
opacity: 0.45;
}
.settings-sidebar-resizing {
transition: none;
user-select: none;
}
body.settings-sidebar-resize-active {
cursor: col-resize;
user-select: none;
}
.settings-sidebar-toggle {
position: absolute;
z-index: 25;
top: 9px;
right: -13px;
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--panel);
color: var(--fg);
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease, border-color 0.12s ease;
}
.settings-sidebar:hover .settings-sidebar-toggle,
.settings-sidebar-toggle:focus-visible,
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
opacity: 1;
}
.settings-sidebar-toggle:hover,
.settings-sidebar-toggle:focus-visible {
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
.settings-sidebar-toggle-expand {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed {
width: 34px;
flex-basis: 34px;
padding: 0;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-resize-handle {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
top: 10px;
right: 5px;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-collapse {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-expand {
display: block;
}
/* The existing narrow/snapped layout is a horizontal tab rail. Sidebar
resizing/collapse applies only to the vertical desktop layout. */
@container settings-modal (max-width: 620px) {
.settings-sidebar {
width: auto;
flex: 0 0 auto;
transition: none;
}
.settings-sidebar-content {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
}
.settings-nav-search-wrap,
.settings-sidebar-resize-handle,
.settings-sidebar-toggle {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed {
width: auto;
flex-basis: auto;
padding: 6px;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
display: flex;
}
}
@media (max-width: 600px) {
.settings-sidebar {
width: auto;
flex: 0 0 auto;
}
.settings-sidebar-content {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
}
.settings-nav-search-wrap,
.settings-sidebar-resize-handle,
.settings-sidebar-toggle {
display: none;
}
}
+5 -77
View File
@@ -7,28 +7,16 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v378-shared-config-image-editor';
const CACHE_NAME = 'odysseus-v376-settings-title-icons';
// Two lists, two jobs — they are no longer the same set and must not be
// "resynced" back into one:
//
// PRECACHE = the app shell. Mirrors the <script type="module"> tags
// and <link rel="stylesheet"> in index.html — i.e. what
// loads before first paint.
// PANEL_PRECACHE = modules that index.html deliberately does NOT load,
// because js/panels.js imports them on first use. They are
// off the critical path, not out of the offline manifest:
// without them here, a panel the user never opened while
// online could not open offline at all.
//
// Both are fetched at install time, in the background. Entries must match the
// exact URL the browser requests, query string included.
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags
// and <link rel="stylesheet"> in index.html.
const PRECACHE = [
'/',
'/static/style.css',
'/static/app.js',
'/static/js/storage.js',
'/static/js/appConfig.js',
'/static/js/ui.js',
'/static/js/markdown.js',
'/static/js/dragSort.js',
@@ -75,73 +63,13 @@ const PRECACHE = [
'/static/lib/highlight.min.js',
];
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
// precached so the panel still opens with no network.
const PANEL_PRECACHE = [
// Image editor — galleryEditor.js and its js/editor/ graph.
'/static/js/galleryEditor.js',
'/static/js/editor/ai-inpaint.js?v=20260708match1',
'/static/js/editor/ai-models.js',
'/static/js/editor/ai-rembg.js',
'/static/js/editor/ai-tool-runner.js',
'/static/js/editor/ai-tools-misc.js',
'/static/js/editor/build/controls.js?v=20260708match1',
'/static/js/editor/build/popups.js',
'/static/js/editor/build/right-panel.js',
'/static/js/editor/build/toolbar.js?v=20260708sam3',
'/static/js/editor/build/topbar.js',
'/static/js/editor/build/transform-popup.js',
'/static/js/editor/canvas-coords.js',
'/static/js/editor/canvas-events.js',
'/static/js/editor/canvas-transforms.js',
'/static/js/editor/checkerboard.js',
'/static/js/editor/clipboard-and-drop.js',
'/static/js/editor/composite-helpers.js',
'/static/js/editor/filters/blur.js',
'/static/js/editor/filters/edge-feather.js',
'/static/js/editor/fx/adj-popup.js',
'/static/js/editor/fx/filter-string.js',
'/static/js/editor/fx/histogram.js',
'/static/js/editor/fx/pixel-pass.js',
'/static/js/editor/harmonize-masks.js',
'/static/js/editor/history-panel.js',
'/static/js/editor/keyboard-shortcuts.js',
'/static/js/editor/layer-helpers.js',
'/static/js/editor/layer-panel.js',
'/static/js/editor/mask-utils.js',
'/static/js/editor/shortcuts-popover.js',
'/static/js/editor/slider-ux.js',
'/static/js/editor/snap.js',
'/static/js/editor/state.js',
'/static/js/editor/stroke-pipeline.js',
'/static/js/editor/stroke-tool-sliders.js',
'/static/js/editor/tools/clone.js',
'/static/js/editor/tools/crop.js',
'/static/js/editor/tools/flood-fill.js',
'/static/js/editor/tools/lasso-mask.js',
'/static/js/editor/tools/lasso.js',
'/static/js/editor/tools/move.js',
'/static/js/editor/tools/stroke.js',
'/static/js/editor/tools/transform-drag.js',
'/static/js/editor/tools/transform-handles.js',
'/static/js/editor/tools/transform-session.js',
'/static/js/editor/tools/wand.js',
'/static/js/editor/wire-import.js',
'/static/js/editor/wire-inpaint-controls.js?v=20260708match1',
'/static/js/editor/wire-merge-buttons.js',
'/static/js/editor/wire-selection-controls.js',
'/static/js/editor/wire-topbar-menus.js',
'/static/js/editor/wire-topbar-overflow.js',
'/static/js/editor/wire-topbar.js',
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then(cache =>
// addAll is atomic — if any item fails, none are cached. Use individual
// puts so a single 404 can't block the whole install.
Promise.all(
[...PRECACHE, ...PANEL_PRECACHE].map(url =>
PRECACHE.map(url =>
fetch(url, { cache: 'reload' })
.then(res => res.ok ? cache.put(url, res) : null)
.catch(() => null)
-775
View File
@@ -1,775 +0,0 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
class ClassList {
constructor() { this.values = new Set(); }
add(...names) { names.filter(Boolean).forEach(name => this.values.add(name)); }
remove(...names) { names.forEach(name => this.values.delete(name)); }
contains(name) { return this.values.has(name); }
toggle(name, force) {
if (force === undefined) force = !this.contains(name);
force ? this.add(name) : this.remove(name);
return force;
}
}
class Style {
constructor() { this.values = {}; this.display = ''; this.cssText = ''; }
setProperty(name, value) { this.values[name] = value; this[name] = value; }
removeProperty(name) { delete this.values[name]; delete this[name]; }
}
class Element {
constructor(tagName, documentRef) {
this.tagName = String(tagName || 'div').toUpperCase();
this.ownerDocument = documentRef;
this.children = [];
this.parentElement = null;
this.attributes = {};
this.dataset = {};
this.classList = new ClassList();
this.style = new Style();
this._listeners = new Map();
this._innerHTML = '';
this._textContent = '';
}
set id(value) { this.attributes.id = String(value); }
get id() { return this.attributes.id || ''; }
set className(value) {
this.classList.values = new Set(String(value || '').split(/\s+/).filter(Boolean));
}
get className() { return Array.from(this.classList.values).join(' '); }
set innerHTML(value) {
this._innerHTML = String(value || '');
if (!this._innerHTML) {
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
}
}
get innerHTML() { return this._innerHTML; }
set textContent(value) {
this._textContent = String(value ?? '');
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
}
get textContent() {
return this._textContent + this.children.map(child => child.textContent).join('');
}
setAttribute(name, value) {
const text = String(value);
this.attributes[name] = text;
if (name === 'class') this.className = text;
if (name.startsWith('data-')) {
const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
this.dataset[key] = text;
}
}
getAttribute(name) { return this.attributes[name] ?? null; }
appendChild(child) {
child.parentElement = this;
this.children.push(child);
return child;
}
append(...children) { children.forEach(child => this.appendChild(child)); }
replaceChildren(...children) {
this.children.forEach(child => { child.parentElement = null; });
this.children = [];
children.forEach(child => this.appendChild(child));
}
contains(candidate) {
if (candidate === this) return true;
return descendants(this).includes(candidate);
}
getBoundingClientRect() {
const width = Number.parseFloat(
this.style.values['--settings-sidebar-width']
|| this.style.width
// This is only a desktop-mode fixture (>620px), not a CSS assertion.
|| (this.classList.contains('settings-modal-content') ? '800' : '220'),
);
return { width, height: 600, left: 0, right: width, top: 0, bottom: 600 };
}
addEventListener(type, handler, options = {}) {
if (!this._listeners.has(type)) this._listeners.set(type, []);
this._listeners.get(type).push({ handler, once: !!options.once });
}
removeEventListener(type, handler) {
const entries = this._listeners.get(type) || [];
this._listeners.set(type, entries.filter(entry => entry.handler !== handler));
}
dispatchEvent(event) {
event.target ||= this;
event.currentTarget = this;
const entries = [...(this._listeners.get(event.type) || [])];
for (const entry of entries) {
entry.handler.call(this, event);
if (entry.once) this.removeEventListener(event.type, entry.handler);
}
}
click() {
this.dispatchEvent({ type: 'click', preventDefault() {}, stopPropagation() {} });
}
matches(selector) { return matchesSelector(this, selector); }
querySelector(selector) { return queryAll(this, selector)[0] || null; }
querySelectorAll(selector) { return queryAll(this, selector); }
closest(selector) {
let node = this;
while (node) {
if (matchesSelector(node, selector)) return node;
node = node.parentElement;
}
return null;
}
}
function simpleMatch(element, selector) {
const text = selector.trim();
if (!text) return false;
const id = text.match(/#([\w-]+)/);
if (id && element.id !== id[1]) return false;
for (const match of text.matchAll(/\.([\w-]+)/g)) {
if (!element.classList.contains(match[1])) return false;
}
for (const match of text.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
const actual = element.getAttribute(match[1]);
if (actual === null) return false;
if (match[2] !== undefined && actual !== match[2]) return false;
}
return true;
}
function matchesSelector(element, selector) {
return selector.split(',').some(part => simpleMatch(element, part));
}
function descendants(root) {
const result = [];
const visit = node => {
for (const child of node.children || []) {
result.push(child);
visit(child);
}
};
visit(root);
return result;
}
function queryAll(root, selector) {
const selectors = selector.split(',').map(item => item.trim()).filter(Boolean);
const nodes = descendants(root);
const result = [];
for (const candidate of nodes) {
if (selectors.some(sel => simpleMatch(candidate, sel))) result.push(candidate);
}
return result;
}
class DocumentShim {
constructor() {
this.body = new Element('body', this);
this.head = new Element('head', this);
this.listeners = new Map();
}
createElement(tag) { return new Element(tag, this); }
getElementById(id) {
return [this.body, this.head, ...descendants(this.body), ...descendants(this.head)]
.find(node => node.id === id) || null;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
return [...queryAll(this.body, selector), ...queryAll(this.head, selector)];
}
addEventListener(type, handler) {
if (!this.listeners.has(type)) this.listeners.set(type, []);
this.listeners.get(type).push(handler);
}
removeEventListener(type, handler) {
this.listeners.set(type, (this.listeners.get(type) || []).filter(item => item !== handler));
}
dispatch(type, event) {
for (const handler of [...(this.listeners.get(type) || [])]) handler(event);
}
}
function buildFixture(document) {
const modal = document.createElement('div');
modal.id = 'settings-modal';
document.body.appendChild(modal);
const header = document.createElement('div');
header.className = 'modal-header';
modal.appendChild(header);
const close = document.createElement('button');
close.className = 'close-btn';
header.appendChild(close);
const content = document.createElement('div');
content.className = 'settings-modal-content modal-content';
modal.appendChild(content);
const nav = document.createElement('div');
nav.className = 'settings-sidebar';
content.appendChild(nav);
const sidebarToggle = document.createElement('button');
sidebarToggle.id = 'settings-sidebar-toggle';
nav.appendChild(sidebarToggle);
const sidebarHandle = document.createElement('div');
sidebarHandle.id = 'settings-sidebar-resize-handle';
nav.appendChild(sidebarHandle);
const sidebarContent = document.createElement('div');
sidebarContent.className = 'settings-sidebar-content';
nav.appendChild(sidebarContent);
const finder = document.createElement('div');
sidebarContent.appendChild(finder);
const searchInput = document.createElement('input');
searchInput.id = 'settings-nav-search';
finder.appendChild(searchInput);
const searchResults = document.createElement('div');
searchResults.id = 'settings-nav-search-results';
searchResults.classList.add('hidden');
finder.appendChild(searchResults);
const panels = document.createElement('div');
content.appendChild(panels);
const makeTab = (id, active = false) => {
const button = document.createElement('button');
button.setAttribute('data-settings-tab', id);
if (active) button.classList.add('active');
sidebarContent.appendChild(button);
const panel = document.createElement('section');
panel.setAttribute('data-settings-panel', id);
if (!active) panel.classList.add('hidden');
panels.appendChild(panel);
return { button, panel };
};
const panelIds = [
'services',
'added-models',
'ai',
'search',
'integrations',
'email',
'reminders',
'appearance',
'shortcuts',
'account',
'tools',
'users',
'system',
];
const settingsPanels = Object.fromEntries(
panelIds.map((id, index) => [id, makeTab(id, index === 0)]),
);
return {
modal,
header,
close,
content,
services: settingsPanels.services,
appearance: settingsPanels.appearance,
system: settingsPanels.system,
settingsPanels,
searchInput,
searchResults,
sidebar: nav,
sidebarToggle,
sidebarHandle,
};
}
function moduleSource(relativePath) {
let source = fs.readFileSync(
path.join(__dirname, '../../static/js/settings', relativePath),
'utf8',
);
// The production files are real ES modules. This lightweight VM harness
// removes imports because dependencies are loaded into the same context,
// but each module still needs its own lexical scope so private const/let
// bindings do not collide across modules.
source = source.replace(/^\s*import[\s\S]*?;\s*$/gm, '');
// Preserve exported API on globalThis while keeping all non-exported
// bindings private inside the module block below.
source = source
.replace(
/\bexport\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g,
'globalThis.$1 = function $1(',
)
.replace(
/\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
)
.replace(
/\bexport\s+let\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
)
.replace(
/\bexport\s+var\s+([A-Za-z_$][\w$]*)\s*=/g,
'globalThis.$1 =',
);
return `{\n${source}\n}`;
}
(function runTests() {
const document = new DocumentShim();
const fixture = buildFixture(document);
const dragCalls = [];
const dockCalls = [];
const removedWindowListeners = [];
const storage = new Map();
const context = {
console,
document,
localStorage: {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, String(value)); },
removeItem(key) { storage.delete(key); },
},
window: {
removeEventListener: (...args) => removedWindowListeners.push(args),
addEventListener() {},
},
makeWindowDraggable: (...args) => dragCalls.push(args),
clearDockSide: (...args) => dockCalls.push(args),
setTimeout: callback => { callback(); return 1; },
WeakSet,
};
vm.createContext(context);
vm.runInContext(moduleSource('registry.js'), context, { filename: 'registry.js' });
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
const results = [];
const check = (test, pass, detail = '') => results.push({ test, pass: Boolean(pass), detail });
const registryPanelIds = vm.runInContext(
'SETTINGS_PANELS.map(panel => panel.id).join(",")',
context,
);
const registryGroupIds = vm.runInContext(
'SETTINGS_GROUPS.map(group => group.id).join(",")',
context,
);
check(
'Settings registry preserves the existing sidebar panel order',
registryPanelIds === [
'services',
'added-models',
'ai',
'search',
'integrations',
'email',
'reminders',
'appearance',
'shortcuts',
'account',
'tools',
'users',
'system',
].join(','),
);
check(
'Settings registry defines the intended information-architecture groups',
registryGroupIds === [
'models',
'communications',
'experience',
'account',
'administration',
].join(','),
);
check(
'Settings registry keeps services, models, integrations and admin panels on the existing admin controller',
['services', 'added-models', 'integrations', 'tools', 'users', 'system']
.every(id => context.isAdminManagedSettingsTab(id))
&& ['ai', 'search', 'email', 'reminders', 'appearance', 'shortcuts', 'account']
.every(id => !context.isAdminManagedSettingsTab(id)),
);
check(
'Settings registry distinguishes admin-only visibility from admin-controlled routing',
['tools', 'users', 'system'].every(id => context.isAdminOnlySettingsTab(id))
&& ['services', 'added-models', 'integrations']
.every(id => !context.isAdminOnlySettingsTab(id)),
);
check(
'Settings registry provides search metadata without owning search UI',
context.getSettingsPanelSearchText('appearance').includes('theme')
&& context.getSettingsPanelSearchText('email').includes('smtp')
&& context.getSettingsPanelSearchText('missing') === '',
);
check(
'Settings registry exposes group membership in sidebar order',
context.getSettingsPanelsForGroup('communications')
.map(panel => panel.id)
.join(',') === 'integrations,email,reminders',
);
check(
'Settings registry matches every production-style tab and panel in the DOM fixture',
context.getSettingsRegistryIssues(fixture.modal).length === 0,
);
check(
'Settings search resolves metadata terms in registry order',
context.searchSettingsPanels('provider', { isAdmin: true })
.map(panel => panel.id)
.join(',') === 'services,added-models,search',
);
check(
'Settings search excludes admin-only panels for non-admin users',
context.searchSettingsPanels('agent tools', { isAdmin: false }).length === 0
&& context.searchSettingsPanels('agent tools', { isAdmin: true })
.map(panel => panel.id)
.join(',') === 'tools',
);
check(
'Settings search requires all query terms',
context.searchSettingsPanels('appearance theme', { isAdmin: false })
.map(panel => panel.id)
.join(',') === 'appearance',
);
let searchedPanel = null;
context.bindSettingsSearch(fixture.modal, {
isAdmin: () => false,
openPanel(tab) { searchedPanel = tab; },
});
fixture.searchInput.value = 'theme';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder renders matching production registry results',
!fixture.searchResults.classList.contains('hidden')
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1
&& fixture.searchResults.querySelector('[data-settings-search-result]').dataset.settingsSearchResult === 'appearance',
);
const finderOutsideTarget = document.createElement('div');
fixture.content.appendChild(finderOutsideTarget);
fixture.modal.dispatchEvent({
type: 'mousedown',
target: finderOutsideTarget,
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder click-away hides results while retaining the query',
fixture.searchInput.value === 'theme'
&& fixture.searchResults.classList.contains('hidden'),
);
fixture.searchInput.dispatchEvent({
type: 'focus',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder refocus restores results for an unchanged retained query',
fixture.searchInput.value === 'theme'
&& !fixture.searchResults.classList.contains('hidden')
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1,
);
fixture.searchInput.dispatchEvent({
type: 'keydown',
key: 'Enter',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder Enter opens the first result and resets the finder',
searchedPanel === 'appearance'
&& fixture.searchInput.value === ''
&& fixture.searchResults.classList.contains('hidden'),
);
searchedPanel = null;
fixture.searchInput.value = 'agent tools';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder does not expose admin-only results to non-admin users',
fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 0
&& fixture.searchResults.textContent !== '',
);
fixture.searchInput.value = 'theme';
fixture.searchInput.dispatchEvent({
type: 'input',
preventDefault() {},
stopPropagation() {},
});
fixture.searchInput.dispatchEvent({
type: 'keydown',
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check(
'Settings finder Escape clears results without closing Settings',
fixture.searchInput.value === ''
&& fixture.searchResults.classList.contains('hidden'),
);
context.setSettingsSidebarWidth(fixture.modal, 280);
check(
'Settings sidebar width is clamped and applied through the production controller',
fixture.sidebar.style.values['--settings-sidebar-width'] === '280px',
);
const widthStorageKey = 'odysseus-settings-sidebar-width';
storage.clear();
const firstRunSidebar = buildFixture(document);
context.bindSettingsSidebar(firstRunSidebar.modal);
check(
'Settings sidebar first bind uses the declared 220px default when storage is empty',
firstRunSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
);
storage.clear();
storage.set(widthStorageKey, '276');
const storedSidebar = buildFixture(document);
context.bindSettingsSidebar(storedSidebar.modal);
check(
'Settings sidebar first bind restores a valid stored width',
storedSidebar.sidebar.style.values['--settings-sidebar-width'] === '276px',
);
storage.clear();
storage.set(widthStorageKey, 'not-a-number');
const malformedSidebar = buildFixture(document);
context.bindSettingsSidebar(malformedSidebar.modal);
check(
'Settings sidebar first bind falls back to default for malformed storage',
malformedSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
);
storage.clear();
storage.set(widthStorageKey, '10');
const minimumSidebar = buildFixture(document);
context.bindSettingsSidebar(minimumSidebar.modal);
storage.clear();
storage.set(widthStorageKey, '999');
const maximumSidebar = buildFixture(document);
context.bindSettingsSidebar(maximumSidebar.modal);
check(
'Settings sidebar first bind clamps stored widths to declared bounds',
minimumSidebar.sidebar.style.values['--settings-sidebar-width'] === '150px'
&& maximumSidebar.sidebar.style.values['--settings-sidebar-width'] === '340px',
);
context.setSettingsSidebarCollapsed(fixture.modal, true);
check(
'Settings sidebar collapse leaves the compact navigation rail state active',
fixture.sidebar.classList.contains('settings-sidebar-collapsed')
&& fixture.sidebarToggle.getAttribute('aria-label') === 'Expand settings navigation',
);
context.setSettingsSidebarCollapsed(fixture.modal, false, { width: 260 });
check(
'Settings sidebar expansion restores the requested expanded width',
!fixture.sidebar.classList.contains('settings-sidebar-collapsed')
&& fixture.sidebar.style.values['--settings-sidebar-width'] === '260px',
);
// Keyboard behavior must be exercised on a fixture that went through the
// real binding path; direct controller calls above intentionally do not bind
// event listeners.
storage.clear();
const keyboardSidebar = buildFixture(document);
context.bindSettingsSidebar(keyboardSidebar.modal);
context.setSettingsSidebarWidth(
keyboardSidebar.modal,
150,
{ persist: false },
);
keyboardSidebar.sidebarHandle.dispatchEvent({
type: 'keydown',
key: 'ArrowLeft',
preventDefault() {},
stopPropagation() {},
});
check(
'ArrowLeft at the sidebar minimum collapses the rail instead of getting stuck at 150px',
keyboardSidebar.sidebar.classList.contains('settings-sidebar-collapsed'),
);
context.setSettingsSidebarCollapsed(
keyboardSidebar.modal,
false,
{ width: 220, persist: false },
);
check(
'resizable Settings separator exposes its current ARIA range and value',
keyboardSidebar.sidebarHandle.getAttribute('aria-valuemin') === '150'
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuemax') === '340'
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuenow') === '220',
);
check('byId resolves elements through the production DOM helper', context.byId('settings-modal') === fixture.modal);
context.activateSettingsPanel(fixture.modal, 'appearance');
check(
'activateSettingsPanel switches sidebar and panel state together',
fixture.appearance.button.classList.contains('active')
&& !fixture.appearance.panel.classList.contains('hidden')
&& !fixture.services.button.classList.contains('active')
&& fixture.services.panel.classList.contains('hidden'),
);
check('getActiveSettingsTab reports the active panel', context.getActiveSettingsTab(fixture.modal) === 'appearance');
let activated = null;
let delegated = null;
context.bindSettingsNavigation(fixture.modal, {
openAdminTab(tab) { delegated = tab; return tab === 'system'; },
onPanelActivated(tab) { activated = tab; },
});
fixture.services.button.click();
check(
'normal navigation activates locally and notifies the coordinator',
activated === 'services' && fixture.services.button.classList.contains('active'),
);
activated = null;
fixture.system.button.click();
check(
'admin navigation delegates without performing a second local activation',
delegated === 'system' && activated === null && fixture.services.button.classList.contains('active'),
);
context.bindSettingsDrag(fixture.modal);
check(
'drag binding preserves the existing Settings drag contract',
dragCalls.length === 1
&& dragCalls[0][0] === fixture.modal
&& dragCalls[0][1].content === fixture.content
&& dragCalls[0][1].header === fixture.header
&& dragCalls[0][1].enableDock === true
&& dragCalls[0][1].skipSelector === 'button, input, select, .theme-opacity-wrap',
);
let disconnected = 0;
fixture.modal.classList.add('modal-left-docked');
fixture.content.style.setProperty('left', '123px');
fixture.content.dataset._tileZone = 'left';
fixture.content._leftDockNavObs = {
navObs: { disconnect() { disconnected += 1; } },
reanchor() {},
};
context.resetSettingsWindowPlacement(fixture.modal);
check(
'window placement reset clears docking observers and inline placement',
!fixture.modal.classList.contains('modal-left-docked')
&& dockCalls.some(call => call[0] === 'left' && call[1] === fixture.modal)
&& disconnected === 1
&& !('_tileZone' in fixture.content.dataset)
&& fixture.content.style.left === undefined
&& removedWindowListeners.some(call => call[0] === 'resize'),
);
fixture.modal.classList.add('modal-right-docked', 'hidden');
context.showSettingsModal(fixture.modal);
check(
'showSettingsModal restores a hidden modal before showing it',
!fixture.modal.classList.contains('hidden')
&& !fixture.modal.classList.contains('modal-right-docked')
&& dockCalls.some(call => call[0] === 'right'),
);
let closeCount = 0;
context.bindSettingsClose(fixture.modal, {
closeSettings() { closeCount += 1; },
isTouchInsideModal() { return false; },
});
const form = document.createElement('div');
form.id = 'unified-intg-form';
form.style.display = '';
form.appendChild(document.createElement('input'));
fixture.content.appendChild(form);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check(
'Escape closes an inner integration editor before closing Settings',
form.style.display === 'none' && form.children.length === 0 && closeCount === 0,
);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check('Escape closes Settings when no nested flow is active', closeCount === 1);
const popover = document.createElement('div');
popover.setAttribute('data-popover-open', '1');
popover.style.display = 'block';
fixture.content.appendChild(popover);
document.dispatch('keydown', {
key: 'Escape',
preventDefault() {},
stopPropagation() {},
});
check('Escape leaves Settings open while a transient popover is active', closeCount === 1);
popover.classList.add('hidden');
context.hideSettingsModal(fixture.modal);
check(
'hideSettingsModal preserves the closing animation fallback semantics',
fixture.modal.classList.contains('hidden') && !fixture.content.classList.contains('modal-closing'),
);
console.log(JSON.stringify(results));
if (results.some(result => !result.pass)) process.exitCode = 1;
})();
File diff suppressed because it is too large Load Diff
-4
View File
@@ -314,21 +314,17 @@ class TestComputeFinalMetrics:
def test_tool_events_included(self):
events = [{"tool": "bash", "duration": 1.0}]
texts = ["round 1 text"]
models = ["round-1-model"]
m = _compute_final_metrics(**self._base_args(
tool_events=events,
round_texts=texts,
round_models=models,
))
assert m["tool_events"] == events
assert m["round_texts"] == texts
assert m["round_models"] == models
def test_no_tool_events_excluded(self):
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
assert "tool_events" not in m
assert "round_texts" not in m
assert "round_models" not in m
# ---------------------------------------------------------------------------
@@ -1,237 +0,0 @@
"""Saved Agent rounds must render and bill with actual per-round provenance."""
import json
from pathlib import Path
import re
import shutil
import subprocess
import pytest
_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "chatRenderer.js"
).read_text(encoding="utf-8")
_CHAT_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "chat.js"
).read_text(encoding="utf-8")
_SLASH_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "slashCommands.js"
).read_text(encoding="utf-8")
_HAS_NODE = shutil.which("node") is not None
def _function_source(name):
match = re.search(
rf"^(?:export )?function {name}\(.*?^\}}",
_SOURCE,
re.MULTILINE | re.DOTALL,
)
assert match, f"{name} not found"
return match.group(0).replace("export function", "function", 1)
def _run_node(source):
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_saved_agent_rounds_prefer_round_model_provenance():
assert "const roundModels = metadata.round_models || [];" in _SOURCE
assert "const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;" in _SOURCE
assert "Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1" in _SOURCE
assert "const roundEndpointIds = metadata.round_endpoint_ids || [];" in _SOURCE
assert "const roundEndpointLabels = metadata.round_endpoint_labels || [];" in _SOURCE
assert "r < roundEndpointIds.length" in _SOURCE
assert "r < roundEndpointLabels.length" in _SOURCE
assert "roundEndpointIds[r] || pair.actualEndpointId" not in _SOURCE
def test_metrics_cost_uses_actual_fallback_endpoint_classification():
assert "metrics.endpoint_cost_tracked" in _SOURCE
assert "endpointCostTracked === false" in _SOURCE
assert "endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)" in _SOURCE
assert "Array.isArray(metrics.usage_buckets)" in _SOURCE
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_agent_usage_buckets_sum_only_billable_answering_routes():
source = "\n".join([
"let currentUrl = '';",
"function _currentEndpointUrl() { return currentUrl; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
"const paidSelected = {usage_buckets: [",
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true},",
" {model: 'local-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: false},",
"]};",
"const localSelected = {usage_buckets: [",
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: false},",
" {model: 'paid-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: true},",
"]};",
"currentUrl = 'local';",
"const paidToLocal = _metricsBillableCost(paidSelected, 'final', 300, 30);",
"currentUrl = 'paid';",
"const localToPaid = _metricsBillableCost(localSelected, 'final', 300, 30);",
"console.log(JSON.stringify({paidToLocal, localToPaid}));",
])
assert _run_node(source) == {"paidToLocal": 0.11, "localToPaid": 0.22}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_force_answer_synthesis_segment_is_included_in_fallback_cost():
source = "\n".join([
"function _currentEndpointUrl() { return 'local-selected'; }",
"function isCostTrackedEndpoint() { return false; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
"const metrics = {usage_buckets: [",
" {round: 6, model: 'paid-fallback', input_tokens: 100, output_tokens: 0, endpoint_cost_tracked: true},",
" {round: 6, model: 'paid-fallback', input_tokens: 80, output_tokens: 20, endpoint_cost_tracked: true},",
"]};",
"console.log(JSON.stringify({cost: _metricsBillableCost(metrics, 'paid-fallback', 180, 20)}));",
])
assert _run_node(source) == {"cost": 0.2}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_repeated_live_metrics_render_records_session_cost_once():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'local'; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
"const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};",
"recordSessionMetricsCost(metrics);",
"recordSessionMetricsCost(metrics);",
"console.log(JSON.stringify({cost: JSON.parse(state[_COST_KEY]).session, recorded: metrics._costRecorded}));",
])
assert _run_node(source) == {"cost": 0.11, "recorded": True}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const _MAX_COST_RUNS_PER_SESSION = 256;",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'local'; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
_function_source("getSessionCost"),
"const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};",
"const replayedObject = {...firstObject};",
"recordSessionMetricsCost(firstObject);",
"recordSessionMetricsCost(replayedObject);",
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
])
assert _run_node(source) == {"cost": 0.11, "runs": {"run-1": 0.11}}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const _MAX_COST_RUNS_PER_SESSION = 256;",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'paid'; }",
"function isCostTrackedEndpoint() { return true; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
_function_source("getSessionCost"),
"recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});",
"recordSessionMetricsCost({model: 'student', input_tokens: 120, output_tokens: 20, _costRecordId: 'run:primary'});",
"recordSessionMetricsCost({model: 'teacher', input_tokens: 200, output_tokens: 30, _costRecordId: 'run:teacher'});",
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
])
assert _run_node(source) == {
"cost": 0.37,
"runs": {"run:primary": 0.14, "run:teacher": 0.23},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const state = {'ody-session-cost': JSON.stringify({session: 0.125})};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const badge = {style: {}, textContent: ''};",
"const document = {getElementById() { return badge; }};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};",
_function_source("getSessionCost"),
_function_source("updateSessionCostUI"),
"updateSessionCostUI();",
"console.log(JSON.stringify({stored: JSON.parse(state[_COST_KEY]).session, display: badge.style.display, text: badge.textContent}));",
])
assert _run_node(source) == {
"stored": 0.125,
"display": "",
"text": "$0.125",
}
def test_live_and_resumed_terminal_events_apply_usage_metrics_before_reload():
assert "metrics = json.data || metrics;" in _CHAT_SOURCE
assert "displayMetrics(terminalMetricsTarget, metrics);" in _CHAT_SOURCE
assert "metricsData = json.data || metricsData;" in _CHAT_SOURCE
assert "displayMetrics(holder, metricsData);" in _CHAT_SOURCE
assert "json.type === 'agent_terminal' || json.type === 'chat_terminal'" in _CHAT_SOURCE
assert "chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);" in _CHAT_SOURCE
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId);" in _CHAT_SOURCE
assert "metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);" in _CHAT_SOURCE
assert "bgTerminal.status = 'completed';" in _CHAT_SOURCE
def test_usage_command_does_not_hide_existing_fallback_cost_for_local_selection():
assert "const cost = chatRenderer.getSessionCost" in _SLASH_SOURCE
assert "const cost = costTracked && chatRenderer.getSessionCost" not in _SLASH_SOURCE

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