mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec4d1805d | ||
|
|
54d794e8de | ||
|
|
3cd6cdb638 |
+2
-29
@@ -76,24 +76,12 @@ 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
|
||||
|
||||
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
||||
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
||||
# force it on, or false to force it off while you still serve plain HTTP.
|
||||
# Upgrading: this used to default to false. Drop a leftover SECURE_COOKIES=false
|
||||
# from your .env unless you still need that escape hatch — it keeps HTTPS logins
|
||||
# on a non-Secure cookie.
|
||||
# Mark session cookies Secure. Set true when Odysseus is served through HTTPS
|
||||
# by a trusted reverse proxy or private access gateway.
|
||||
# SECURE_COOKIES=true
|
||||
|
||||
# Optional: pre-seed the first admin password during setup.
|
||||
@@ -163,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
|
||||
# ============================================================
|
||||
|
||||
@@ -15,13 +15,6 @@ docker/entrypoint.sh text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
|
||||
# Vendored third-party bundles in static/lib/ are published minified artifacts
|
||||
# and must stay byte-identical to what npm ships — stripping trailing whitespace
|
||||
# to satisfy `git diff --check` would desync them from the upstream release. Turn
|
||||
# the whitespace check off for that tree instead, and keep the bundles out of
|
||||
# GitHub's language statistics.
|
||||
static/lib/** -whitespace linguist-vendored
|
||||
|
||||
# Binary assets — never normalize.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
|
||||
@@ -26,18 +26,6 @@ body:
|
||||
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: revision
|
||||
attributes:
|
||||
label: Odysseus Revision
|
||||
description: |
|
||||
From the repository root (on the host when using Docker), run
|
||||
`git show -s --abbrev=12 --format='%h (%cs)' HEAD`
|
||||
and paste the output exactly.
|
||||
placeholder: "1fef4929cf1d (2026-08-11)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -41,14 +41,6 @@ module.exports = async ({ github, context, core }) => {
|
||||
break;
|
||||
|
||||
case 'bug': {
|
||||
const revisionText = section('Odysseus Revision');
|
||||
if (!/^[0-9a-f]{12} \(\d{4}-\d{2}-\d{2}\)$/i.test(revisionText)) {
|
||||
failures.push(
|
||||
'**Odysseus Revision** — paste the 12-character commit SHA and date, ' +
|
||||
'for example `1fef4929cf1d (2026-08-11)`',
|
||||
);
|
||||
}
|
||||
|
||||
if (!section('Install Method')) {
|
||||
failures.push('**Install Method** — select how you installed Odysseus');
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-10
@@ -65,16 +65,6 @@ Vendored in `static/lib/` and served directly:
|
||||
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
|
||||
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
|
||||
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
|
||||
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
|
||||
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
|
||||
|
||||
KaTeX and Mermaid are loaded on first use by `static/js/markdown.js` rather than
|
||||
from `index.html`, so a session that renders no math and no diagram never fetches
|
||||
either. Only the `.woff2` KaTeX fonts are shipped, matching `static/fonts/`; the
|
||||
`.woff` and `.ttf` variants its stylesheet also lists are never requested by a
|
||||
browser that supports `woff2`. The bundles are the published npm artifacts,
|
||||
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
|
||||
so they can stay byte-identical to upstream.
|
||||
|
||||
## Front-end libraries loaded at runtime (CDN)
|
||||
|
||||
@@ -82,6 +72,8 @@ Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not v
|
||||
|
||||
| Library | Purpose | License |
|
||||
|---|---|---|
|
||||
| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT |
|
||||
| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT |
|
||||
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
|
||||
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
|
||||
|
||||
|
||||
@@ -59,20 +59,15 @@ 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
|
||||
|
||||
<a href="https://star-history.dera.page/#odysseus-dev/odysseus&type=date&legend=top-left">
|
||||
<a href="https://www.star-history.com/?repos=odysseus-dev%2Fodysseus&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Security fixes are handled on the default branch until formal releases are cut.
|
||||
|
||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Set `SECURE_COOKIES=true` to force it on (for a proxy Odysseus cannot see the scheme of), or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
||||
- Set `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
||||
- Use HTTPS when exposing the app beyond localhost.
|
||||
- Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN.
|
||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -784,7 +771,7 @@ from src.task_scheduler import TaskScheduler
|
||||
task_scheduler = TaskScheduler(session_manager)
|
||||
from src.event_bus import set_task_scheduler
|
||||
set_task_scheduler(task_scheduler)
|
||||
from routes.task.task_routes import setup_task_routes
|
||||
from routes.task_routes import setup_task_routes
|
||||
app.include_router(setup_task_routes(task_scheduler))
|
||||
|
||||
from routes.assistant_routes import setup_assistant_routes
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
+3
-29
@@ -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")
|
||||
|
||||
+1
-17
@@ -14,8 +14,6 @@ import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, Optional
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
||||
from .models import Session, ChatMessage
|
||||
from src.attachment_refs import persistable_message_content
|
||||
@@ -94,28 +92,14 @@ class SessionManager:
|
||||
try:
|
||||
db_sessions = db.query(DbSession).filter(
|
||||
DbSession.archived == False,
|
||||
DbSession.messages.any(),
|
||||
DbSession.message_count > 0,
|
||||
).order_by(DbSession.last_accessed.desc()).limit(100).all()
|
||||
|
||||
# message_count is derived metadata and can drift after interrupted
|
||||
# or legacy writes. Count only the bounded discovery set so startup
|
||||
# remains metadata-only while lazy hydration sees an authoritative
|
||||
# positive count for every discovered non-empty session.
|
||||
message_counts = {}
|
||||
if db_sessions:
|
||||
message_counts = dict(
|
||||
db.query(DbChatMessage.session_id, func.count(DbChatMessage.id))
|
||||
.filter(DbChatMessage.session_id.in_([row.id for row in db_sessions]))
|
||||
.group_by(DbChatMessage.session_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
loaded_count = 0
|
||||
for db_session in db_sessions:
|
||||
try:
|
||||
session = self._db_to_session_meta(db_session)
|
||||
if session is not None:
|
||||
session.message_count = message_counts[db_session.id]
|
||||
self.sessions[db_session.id] = session
|
||||
loaded_count += 1
|
||||
except Exception as e:
|
||||
|
||||
@@ -46,11 +46,10 @@ 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}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||
@@ -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
|
||||
|
||||
@@ -45,11 +45,10 @@ 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}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||
@@ -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
|
||||
|
||||
+1
-7
@@ -34,11 +34,10 @@ 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}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||
@@ -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
|
||||
|
||||
+8
-30
@@ -441,19 +441,10 @@ A grab-bag of small gotchas that otherwise turn into long debugging sessions.
|
||||
| Package | Feature unlocked |
|
||||
|---------|-----------------|
|
||||
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
|
||||
| `kokoro`, `soundfile` | Local Kokoro-82M text-to-speech on a CUDA GPU. The pinned Kokoro release supports Odysseus installs on Python 3.11-3.12; these packages are intentionally skipped on Python 3.13+ (including the Python 3.14 container image). |
|
||||
| `ddgs` | DuckDuckGo as a search provider option. |
|
||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
||||
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||
|
||||
Install the optional set only when you need these features:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-optional.txt
|
||||
```
|
||||
|
||||
The default Docker image currently uses Python 3.14, while Kokoro 0.9.4 declares Python `>=3.10,<3.13`. Odysseus itself continues to support Python 3.11+, but this pinned optional local-TTS feature requires a native Python 3.11 or 3.12 environment. Kokoro declares `torch`, but the local provider only activates when that torch build has CUDA and a GPU is visible; install the CUDA build appropriate for your host. Browser and configured endpoint TTS remain available on Python 3.13+ and in the container image.
|
||||
|
||||
### Faster, reproducible installs with uv (optional)
|
||||
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
||||
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
||||
@@ -484,7 +475,7 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
|
||||
|
||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Use `SECURE_COOKIES=true` to force it on for a proxy whose scheme Odysseus cannot see, or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
||||
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
||||
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
||||
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
|
||||
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
|
||||
@@ -495,14 +486,6 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
|
||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
||||
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
|
||||
|
||||
> **Upgrading an existing install:** `SECURE_COOKIES` used to default to
|
||||
> `false`, so an install set up before scheme derivation may still carry
|
||||
> `SECURE_COOKIES=false` in its own `.env`. That explicit value stays
|
||||
> authoritative, so HTTPS logins keep getting a non-`Secure` session cookie.
|
||||
> Pulling this change updates the tracked Compose files, but nothing rewrites
|
||||
> your `.env` — drop the line from it unless you deliberately serve plain HTTP
|
||||
> alongside HTTPS and want the escape hatch.
|
||||
|
||||
### Private or proxied deployments
|
||||
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
|
||||
|
||||
@@ -511,7 +494,7 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
|
||||
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
||||
4. Keep raw service and model ports internal-only.
|
||||
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true` and `LOCALHOST_BYPASS=false`. Any proxy that forwards `X-Forwarded-Proto: https` gets `Secure` session cookies without configuration, so `SECURE_COOKIES` only needs setting when you want to override that — force it on for a proxy that forwards no scheme at all, or off while you still serve plain HTTP.
|
||||
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
|
||||
@@ -599,12 +582,9 @@ 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.
|
||||
|
||||
A proxy that exposes the HTTPS request scheme to Odysseus needs no `SECURE_COOKIES` setting. Only force it on when the proxy cannot expose that scheme:
|
||||
**4. Point Odysseus at the new origin** in `.env`, then restart it:
|
||||
|
||||
```bash
|
||||
# only if the proxy cannot expose the external HTTPS scheme to Odysseus:
|
||||
SECURE_COOKIES=true
|
||||
# only if you use remote MCP servers with OAuth:
|
||||
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
|
||||
@@ -639,12 +619,10 @@ enable it by right-clicking the column headers.
|
||||
|
||||
Three things bite when moving an existing install behind TLS:
|
||||
|
||||
- Leave `SECURE_COOKIES` unset when Odysseus can see the external HTTPS scheme;
|
||||
the cookie then follows the request automatically. If your proxy cannot expose
|
||||
that scheme, set `SECURE_COOKIES=true` **at the same time** you stop serving
|
||||
plain HTTP, not before. An explicit `true` applies to every login, so while an
|
||||
HTTP entrypoint is still reachable the browser will reject the `Secure` cookie
|
||||
there and login will appear to loop.
|
||||
- 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
|
||||
@@ -697,7 +675,7 @@ Key settings:
|
||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
||||
| `SECURE_COOKIES` | derived from the request scheme | Marks session cookies `Secure` on HTTPS requests. Set true to force it on, false to force it off. |
|
||||
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
||||
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 - 2022 Knut Sveidqvist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -12,16 +12,6 @@
|
||||
# GPU-accelerated transcription — it's auto-detected, CPU is used otherwise.
|
||||
faster-whisper
|
||||
|
||||
# Local text-to-speech via Kokoro-82M for the "local" TTS provider.
|
||||
# Kokoro 0.9.4 declares Python >=3.10,<3.13; Odysseus itself requires 3.11+,
|
||||
# so pip installs these extras on 3.11-3.12 and deliberately skips them on
|
||||
# Python 3.13+ (including the Python 3.14 container image). Kokoro declares
|
||||
# torch; the local provider still
|
||||
# requires a CUDA-enabled torch build and GPU at runtime. SoundFile is separate
|
||||
# in Kokoro's official install instructions and is not a transitive dependency.
|
||||
kokoro==0.9.4; python_version >= "3.11" and python_version < "3.13"
|
||||
soundfile; python_version >= "3.11" and python_version < "3.13"
|
||||
|
||||
# DuckDuckGo as a search provider option.
|
||||
# Install if you want DDG in the search-provider dropdown.
|
||||
# Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-28
@@ -86,33 +86,6 @@ class SetOpenRegistrationRequest(BaseModel):
|
||||
SESSION_COOKIE = "odysseus_session"
|
||||
|
||||
|
||||
def _secure_cookie(request: Request) -> bool:
|
||||
"""Decide the ``Secure`` attribute of the session cookie.
|
||||
|
||||
``SECURE_COOKIES`` stays authoritative when it holds an explicit value:
|
||||
``true`` always marks the cookie Secure (the documented knob for a TLS
|
||||
proxy), ``false`` never does, which is the escape hatch for an install
|
||||
that still answers on plain HTTP alongside HTTPS. Anything else —
|
||||
unset, or the present-but-empty value docker-compose injects for a
|
||||
variable the host has not defined — derives it from the request, so an
|
||||
HTTPS login gets a Secure cookie without any configuration.
|
||||
|
||||
Either the connection scheme or ``X-Forwarded-Proto`` saying https is
|
||||
enough, which is the same test ``core/middleware.py`` applies before it
|
||||
sends HSTS. Uvicorn's proxy-headers middleware already folds that header
|
||||
into the scheme for the proxies it trusts, so reading it here only adds
|
||||
the case of a terminator that is not on a trusted address; the cost is
|
||||
that a client talking to the app directly can set the header and lock
|
||||
its own session out over plain HTTP.
|
||||
"""
|
||||
configured = os.getenv("SECURE_COOKIES", "").strip().lower()
|
||||
if configured in ("true", "false"):
|
||||
return configured == "true"
|
||||
# A chained proxy sends a list — the client-facing hop comes first.
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0]
|
||||
return request.url.scheme == "https" or forwarded_proto.strip().lower() == "https"
|
||||
|
||||
|
||||
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -186,7 +159,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=_secure_cookie(request),
|
||||
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
|
||||
path="/",
|
||||
)
|
||||
if body.remember:
|
||||
|
||||
+3
-103
@@ -67,7 +67,6 @@ from src.tool_policy import (
|
||||
is_web_search_explicitly_denied,
|
||||
web_search_enabled_for_turn,
|
||||
)
|
||||
from src.tool_approvals import tool_approval_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -906,18 +905,6 @@ def setup_chat_routes(
|
||||
incognito = str(form_data.get("incognito", "")).lower() == "true"
|
||||
plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true"
|
||||
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
|
||||
tool_approval_id = (
|
||||
form_data.get("tool_approval_id")
|
||||
or (body or {}).get("tool_approval_id")
|
||||
)
|
||||
tool_approval_decision = (
|
||||
form_data.get("tool_approval_decision")
|
||||
or (body or {}).get("tool_approval_decision")
|
||||
)
|
||||
exact_tool_approval = None
|
||||
pending_tool_approval = None
|
||||
retired_tool_approval_taint = False
|
||||
tool_approval_continuation = False
|
||||
# Workspace: confine the agent's file/shell tools to this folder.
|
||||
workspace, workspace_rejected = _resolve_request_workspace(
|
||||
request, form_data.get("workspace")
|
||||
@@ -1064,74 +1051,6 @@ def setup_chat_routes(
|
||||
_verify_session_owner(request, session)
|
||||
sess = session_manager.get_session(session)
|
||||
owner = effective_user(request)
|
||||
if tool_approval_id:
|
||||
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
|
||||
normalized_owner = str(owner or "").strip().casefold()
|
||||
if (
|
||||
pending_tool_approval is None
|
||||
or pending_tool_approval.owner != normalized_owner
|
||||
or pending_tool_approval.session_id != str(session)
|
||||
):
|
||||
raise HTTPException(
|
||||
409,
|
||||
"This tool approval is invalid, expired, or belongs to another thread.",
|
||||
)
|
||||
decision = str(tool_approval_decision or "").strip().lower()
|
||||
if decision not in {"approve", "deny"}:
|
||||
raise HTTPException(400, "Invalid tool approval decision.")
|
||||
if plan_mode:
|
||||
raise HTTPException(
|
||||
409,
|
||||
"Tool approvals cannot be consumed while plan mode is active.",
|
||||
)
|
||||
exact_tool_approval = tool_approval_store.consume(
|
||||
tool_approval_id,
|
||||
decision=decision,
|
||||
owner=owner,
|
||||
session_id=session,
|
||||
)
|
||||
tool_approval_continuation = True
|
||||
if decision == "approve" and exact_tool_approval is None:
|
||||
raise HTTPException(
|
||||
409,
|
||||
"This tool approval could not be consumed.",
|
||||
)
|
||||
if decision == "approve":
|
||||
message = (
|
||||
f"Approved the exact {pending_tool_approval.tool_name} action "
|
||||
"shown above once."
|
||||
)
|
||||
# The sealed server record, not mutable composer state,
|
||||
# restores the original action workspace.
|
||||
workspace = pending_tool_approval.workspace or None
|
||||
workspace_rejected = None
|
||||
if pending_tool_approval.document_id:
|
||||
active_doc_id = pending_tool_approval.document_id
|
||||
# The approval click is the per-turn opt-in for this exact
|
||||
# sealed action. Restore only the coarse request toggle
|
||||
# that would otherwise disable it because the synthetic
|
||||
# "Approved…" message no longer resembles the original
|
||||
# shell/web request. Current privilege, global-disable,
|
||||
# incognito, compare, and tool-policy gates still run.
|
||||
if pending_tool_approval.tool_name == "bash":
|
||||
allow_bash = "true"
|
||||
if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
|
||||
allow_web_search = "true"
|
||||
_search_enabled = True
|
||||
else:
|
||||
message = (
|
||||
f"Denied the {pending_tool_approval.tool_name} action shown above."
|
||||
)
|
||||
chat_mode = "agent"
|
||||
else:
|
||||
# A normal user message supersedes the card that was waiting
|
||||
# in this thread. Retire its opaque grant, but preserve the
|
||||
# originating provenance for this turn so dismissing a card
|
||||
# cannot make the same model-requested action authoritative.
|
||||
retired_tool_approval_taint = tool_approval_store.retire_for_session(
|
||||
owner=owner,
|
||||
session_id=session,
|
||||
)
|
||||
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
@@ -1199,24 +1118,14 @@ def setup_chat_routes(
|
||||
resolve_session_auth(sess, session, owner=effective_user(request))
|
||||
|
||||
# Check for research_pending BEFORE mode persist overwrites it
|
||||
# An approval response resumes the sealed agent action. Do not let
|
||||
# mutable form fields, or a stale research_pending session marker,
|
||||
# consume the one-use grant on the unrelated research path.
|
||||
do_research = (
|
||||
not tool_approval_continuation
|
||||
and str(use_research).lower() == "true"
|
||||
)
|
||||
if not do_research and not tool_approval_continuation:
|
||||
do_research = str(use_research).lower() == "true"
|
||||
if not do_research:
|
||||
if get_session_mode(session) == 'research_pending':
|
||||
do_research = True
|
||||
logger.info(f"Session {session} in research_pending — auto-triggering research")
|
||||
|
||||
att_ids = []
|
||||
if tool_approval_continuation:
|
||||
# Browser composer state is unrelated to the action that was
|
||||
# reviewed. The original turn remains in session history.
|
||||
att_ids = []
|
||||
elif body and isinstance(body.get("attachments"), list):
|
||||
if body and isinstance(body.get("attachments"), list):
|
||||
att_ids = [str(x) for x in body["attachments"]]
|
||||
elif attachments:
|
||||
try:
|
||||
@@ -2226,15 +2135,6 @@ def setup_chat_routes(
|
||||
forced_tools=_forced_tools,
|
||||
uploaded_files=ctx.uploaded_files,
|
||||
defer_context_shaping=_foreground_policy.enabled,
|
||||
external_untrusted_context_seen=bool(
|
||||
retired_tool_approval_taint
|
||||
or (
|
||||
tool_approval_continuation
|
||||
and pending_tool_approval
|
||||
and pending_tool_approval.external_untrusted_context_seen
|
||||
)
|
||||
),
|
||||
exact_approval=exact_tool_approval,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
|
||||
@@ -1204,41 +1204,6 @@ def _safe_env_prefix(ep: str | None) -> str | None:
|
||||
return f'[ -f "{path}" ] && source "{path}" || true'
|
||||
|
||||
|
||||
def _local_windows_bash_env_prefix(ep: str | None) -> str | None:
|
||||
"""Convert a frontend PowerShell venv prefix for the local Git Bash runner."""
|
||||
if not ep:
|
||||
return ep
|
||||
|
||||
prefix = ep.strip()
|
||||
if not prefix.startswith("&"):
|
||||
return ep
|
||||
|
||||
raw_path = prefix[1:].lstrip()
|
||||
if not raw_path:
|
||||
return ep
|
||||
if raw_path.startswith("'"):
|
||||
if len(raw_path) < 2 or not raw_path.endswith("'"):
|
||||
return ep
|
||||
quoted_path = raw_path[1:-1]
|
||||
if "'" in quoted_path.replace("''", ""):
|
||||
return ep
|
||||
path = quoted_path.replace("''", "'")
|
||||
else:
|
||||
path = raw_path.rstrip()
|
||||
if "'" in path or '"' in path:
|
||||
return ep
|
||||
if any(c in path for c in "\r\n;&|`$<>"):
|
||||
return ep
|
||||
if not path.replace("\\", "/").casefold().endswith("/scripts/activate.ps1"):
|
||||
return ep
|
||||
|
||||
bash_path = _git_bash_path(path)
|
||||
if "\\" in bash_path:
|
||||
return ep
|
||||
bash_path = bash_path[: -len("Activate.ps1")] + "activate"
|
||||
return "source " + shlex.quote(bash_path)
|
||||
|
||||
|
||||
def _ssh_ps(host, script_path, port=None):
|
||||
"""Build SSH command to run a PowerShell script on a Windows remote."""
|
||||
pf = f"-p {port} " if port and port != "22" else ""
|
||||
|
||||
@@ -50,7 +50,7 @@ from routes.cookbook_helpers import (
|
||||
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
|
||||
_validate_local_dir, _validate_gpus, _shell_path,
|
||||
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT,
|
||||
_safe_env_prefix, _local_windows_bash_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
|
||||
load_stored_hf_token,
|
||||
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
|
||||
@@ -1336,7 +1336,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# Local: run hf download in the background (tmux on POSIX, a detached
|
||||
# process + logfile on Windows where tmux doesn't exist).
|
||||
if req.env_prefix:
|
||||
lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
|
||||
lines.append(_safe_env_prefix(req.env_prefix))
|
||||
else:
|
||||
lines.append("deactivate 2>/dev/null; hash -r")
|
||||
# Show whether the HF token reached this run (masked) — tells a gated
|
||||
@@ -2166,7 +2166,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if req.gpus:
|
||||
runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'")
|
||||
if req.env_prefix:
|
||||
runner_lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
|
||||
runner_lines.append(_safe_env_prefix(req.env_prefix))
|
||||
else:
|
||||
runner_lines.append("deactivate 2>/dev/null; hash -r")
|
||||
_append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1351,14 +1351,14 @@ def _legacy_visible_api_models(ep) -> List[str]:
|
||||
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
|
||||
"""Return model IDs that should appear in the picker for an endpoint.
|
||||
|
||||
API providers expose remote inventory from /v1/models. Default to that
|
||||
visible inventory until an explicit pinned-model allow-list is saved.
|
||||
Local/self-hosted endpoints keep the older hide-list behavior.
|
||||
API providers expose remote inventory from /v1/models. Treat that cache as
|
||||
inventory, not approval: only manually pinned API models should appear in
|
||||
the picker. Local/self-hosted endpoints keep the older hide-list behavior.
|
||||
"""
|
||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||
if _picker_requires_pinning(base_url, kind):
|
||||
if not _has_explicit_pinned_models(ep):
|
||||
pinned = _legacy_visible_api_models(ep)
|
||||
pinned = _legacy_visible_api_models(ep) if _hidden_model_ids(ep) else []
|
||||
return pinned, pinned
|
||||
return _visible_models(
|
||||
_cached_model_ids(ep),
|
||||
@@ -2342,7 +2342,9 @@ def setup_model_routes(model_discovery):
|
||||
else:
|
||||
response.headers["X-Model-Refresh-Status"] = "failed"
|
||||
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
|
||||
_, pinned = _picker_models_for_endpoint(ep, base, kind)
|
||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||
if picker_requires_pinning and not _has_explicit_pinned_models(ep):
|
||||
pinned = _legacy_visible_api_models(ep)
|
||||
pinned_set = set(pinned)
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Task route domain package (slice 2p, #4082/#4071).
|
||||
|
||||
Contains task_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/task_routes.py re-exports from here.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
+1177
-14
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"""odysseus-webhook — shell wrapper for scheduled-task webhook tokens.
|
||||
|
||||
Tasks in the scheduled-task system can carry a `webhook_token`. Any
|
||||
HTTP POST to `/api/tasks/<task-id>/webhook/<token>` fires the task. This CLI lists,
|
||||
HTTP POST to `/api/webhook/<token>` fires the task. This CLI lists,
|
||||
rotates, and revokes those tokens.
|
||||
|
||||
odysseus-webhook list # tasks that have a token
|
||||
@@ -21,7 +21,6 @@ quiet_logs()
|
||||
|
||||
import argparse, json, logging, os, secrets, sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
from core.database import SessionLocal, ScheduledTask
|
||||
@@ -54,14 +53,6 @@ def _summary(t: "ScheduledTask", reveal: bool = False) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _task_webhook_url(base: str, task_id: str, token: str) -> str:
|
||||
"""Build the live task-route URL without leaking ids into path syntax."""
|
||||
root = (base or "http://localhost:7000").rstrip("/")
|
||||
task_part = quote(str(task_id), safe="")
|
||||
token_part = quote(str(token), safe="")
|
||||
return f"{root}/api/tasks/{task_part}/webhook/{token_part}"
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -118,7 +109,8 @@ def cmd_url(args):
|
||||
fail(f"no task with id {args.id!r}")
|
||||
if not t.webhook_token:
|
||||
fail(f"task {args.id!r} has no webhook token (rotate one first)")
|
||||
url = _task_webhook_url(args.base, t.id, t.webhook_token)
|
||||
base = (args.base or "http://localhost:7000").rstrip("/")
|
||||
url = f"{base}/api/webhook/{t.webhook_token}"
|
||||
emit({
|
||||
"task_id": t.id,
|
||||
"name": t.name,
|
||||
|
||||
+11
-41
@@ -50,46 +50,16 @@ class DocsService:
|
||||
List of DocChunk objects
|
||||
"""
|
||||
results = self.rag.search(query, k=top_k)
|
||||
chunks = []
|
||||
|
||||
for result in results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
|
||||
metadata = result.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
text = result.get("document")
|
||||
if text is None:
|
||||
text = result.get("text")
|
||||
if text is None:
|
||||
text = result.get("content")
|
||||
if text is None:
|
||||
text = ""
|
||||
|
||||
source = result.get("source")
|
||||
if source is None:
|
||||
source = metadata.get("source")
|
||||
if source is None:
|
||||
source = "unknown"
|
||||
|
||||
score = result.get("similarity")
|
||||
if score is None:
|
||||
score = result.get("score")
|
||||
if score is None:
|
||||
score = 0.0
|
||||
|
||||
chunks.append(
|
||||
DocChunk(
|
||||
text=text,
|
||||
source=source,
|
||||
score=score,
|
||||
metadata=metadata,
|
||||
)
|
||||
return [
|
||||
DocChunk(
|
||||
text=r.get("text", r.get("content", "")),
|
||||
source=r.get("source", r.get("metadata", {}).get("source", "unknown")),
|
||||
score=r.get("score", 0.0),
|
||||
metadata=r.get("metadata"),
|
||||
)
|
||||
|
||||
return chunks
|
||||
for r in results
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
|
||||
async def index(self, directory: str) -> IndexResult:
|
||||
"""
|
||||
@@ -103,8 +73,8 @@ class DocsService:
|
||||
"""
|
||||
result = self.rag.index_personal_documents(directory)
|
||||
return IndexResult(
|
||||
indexed=result.get("indexed_count", result.get("indexed", 0)),
|
||||
failed=result.get("failed_count", result.get("failed", 0)),
|
||||
indexed=result.get("indexed", 0),
|
||||
failed=result.get("failed", 0),
|
||||
errors=result.get("errors", []),
|
||||
)
|
||||
|
||||
|
||||
+331
-31
@@ -2,18 +2,22 @@
|
||||
|
||||
import copy
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from typing import Iterable, List, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
import httpcore
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
|
||||
from src import outbound_fetch as _outbound_fetch
|
||||
|
||||
from .analytics import RateLimitError, error_logger
|
||||
from .cache import (
|
||||
@@ -25,40 +29,336 @@ from .cache import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _is_private_address(addr):
|
||||
return _outbound_fetch._is_private_address(addr)
|
||||
_PRIVATE_NETWORKS = (
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hostname_ips(hostname):
|
||||
return _outbound_fetch._resolve_hostname_ips(hostname)
|
||||
|
||||
|
||||
def _public_http_url(url):
|
||||
return _outbound_fetch._public_http_url(url, resolver=_resolve_hostname_ips)
|
||||
|
||||
|
||||
def _resolve_public_ips(url):
|
||||
return _outbound_fetch._resolve_public_ips(url, resolver=_resolve_hostname_ips)
|
||||
|
||||
|
||||
_PinnedBackend = _outbound_fetch._PinnedBackend
|
||||
_PinnedTransport = _outbound_fetch._PinnedTransport
|
||||
BodyTooLargeError = _outbound_fetch.BodyTooLargeError
|
||||
_CappedFetch = _outbound_fetch._CappedFetch
|
||||
|
||||
|
||||
def _get_public_url(url, headers, timeout, max_redirects=5, max_bytes=None):
|
||||
return _outbound_fetch._get_public_url(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
max_redirects=max_redirects,
|
||||
max_bytes=max_bytes,
|
||||
resolve_public_ips=_resolve_public_ips,
|
||||
transport_factory=_PinnedTransport,
|
||||
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return (
|
||||
addr.is_private
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_reserved
|
||||
or addr.is_multicast
|
||||
or addr.is_unspecified
|
||||
or any(addr in net for net in _PRIVATE_NETWORKS)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for info in infos:
|
||||
try:
|
||||
out.append(ipaddress.ip_address(info[4][0]))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _public_http_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
host = (parsed.hostname or "").strip()
|
||||
if not host:
|
||||
return False
|
||||
lower = host.lower()
|
||||
if lower in ("localhost", "metadata", "metadata.google.internal"):
|
||||
return False
|
||||
if lower.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
|
||||
return False
|
||||
try:
|
||||
return not _is_private_address(ipaddress.ip_address(host))
|
||||
except ValueError:
|
||||
pass
|
||||
addrs = _resolve_hostname_ips(host)
|
||||
return bool(addrs) and not any(_is_private_address(a) for a in addrs)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_public_ips(url: str) -> list[ipaddress._BaseAddress]:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
if host in ("localhost", "metadata", "metadata.google.internal"):
|
||||
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if _is_private_address(ip):
|
||||
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
|
||||
return [ip]
|
||||
except httpx.RequestError:
|
||||
raise
|
||||
except ValueError:
|
||||
pass
|
||||
addrs = _resolve_hostname_ips(host)
|
||||
if not addrs or any(_is_private_address(a) for a in addrs):
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
return addrs
|
||||
|
||||
|
||||
class _PinnedBackend(httpcore.NetworkBackend):
|
||||
"""Network backend that connects to a pre-resolved IP.
|
||||
|
||||
httpcore derives the TLS SNI and the ``Host`` header from the URL's
|
||||
origin, not from the host argument passed to ``connect_tcp``. So
|
||||
routing the TCP connect to a resolved IP while leaving the URL
|
||||
untouched keeps SNI / vhost behaviour correct and closes the
|
||||
DNS-rebinding TOCTOU between the SSRF check and the connect.
|
||||
"""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress):
|
||||
self._ip = str(ip)
|
||||
self._real = httpcore.SyncBackend()
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options=None,
|
||||
):
|
||||
return self._real.connect_tcp(
|
||||
self._ip, port, timeout, local_address, socket_options
|
||||
)
|
||||
|
||||
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
return self._real.sleep(seconds)
|
||||
|
||||
|
||||
# Map httpcore exception classes to their httpx equivalents. Built
|
||||
# once at import time from the public exception classes; avoids any
|
||||
# import of httpx's private transport machinery. httpcore's
|
||||
# ``ConnectionNotAvailable`` is a pool-internal signal (the pool will
|
||||
# close and retry on its own) — we never expect to see it surface to
|
||||
# a transport caller, so it has no httpx counterpart here.
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ProxyError: httpx.ProxyError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedTransport(httpx.BaseTransport):
|
||||
"""Transport that pins every TCP connect to a pre-resolved IP.
|
||||
|
||||
Uses only the public ``httpcore`` and ``httpx`` APIs — no
|
||||
subclassing of ``httpx.HTTPTransport``, no reads of private
|
||||
``httpcore.ConnectionPool`` attributes, no imports from
|
||||
``httpx private transport internals``. The URL is passed through unchanged so SNI
|
||||
/ vhost work as if httpx had been given the hostname directly;
|
||||
only the TCP destination is pinned, closing the DNS-rebinding
|
||||
TOCTOU between the SSRF check and the connect.
|
||||
"""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
|
||||
self._pool = httpcore.ConnectionPool(
|
||||
ssl_context=ssl.create_default_context(),
|
||||
http1=True,
|
||||
http2=http2,
|
||||
network_backend=_PinnedBackend(ip),
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
self._pool.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
|
||||
self._pool.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
httpcore_req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
try:
|
||||
httpcore_resp = self._pool.handle_request(httpcore_req)
|
||||
# Eager materialisation matches the original
|
||||
# ``response.text`` usage in fetch_webpage_content. The
|
||||
# sync pool's stream is a plain Iterable[bytes] despite
|
||||
# the httpcore type hint unioning the async variant.
|
||||
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
|
||||
return httpx.Response(
|
||||
status_code=httpcore_resp.status,
|
||||
headers=httpcore_resp.headers,
|
||||
content=content,
|
||||
extensions=httpcore_resp.extensions,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
class BodyTooLargeError(Exception):
|
||||
"""The server declared a body larger than the hard fetch ceiling."""
|
||||
|
||||
def __init__(self, url: str, declared_bytes: int):
|
||||
self.url = url
|
||||
self.declared_bytes = declared_bytes
|
||||
super().__init__(
|
||||
f"response body is {declared_bytes:,} bytes, over the "
|
||||
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
|
||||
)
|
||||
|
||||
|
||||
class _CappedFetch:
|
||||
"""Result of a size-capped streaming GET.
|
||||
|
||||
Carries just what fetch_webpage_content needs from an httpx.Response,
|
||||
plus the cap bookkeeping: the (possibly truncated) body, whether the
|
||||
cap cut it short, and the size the server declared via Content-Length
|
||||
(wire bytes; None when absent).
|
||||
"""
|
||||
|
||||
__slots__ = ("status_code", "headers", "content", "truncated",
|
||||
"declared_bytes", "encoding", "url")
|
||||
|
||||
def __init__(self, status_code, headers, content, truncated,
|
||||
declared_bytes, encoding, url):
|
||||
self.status_code = status_code
|
||||
self.headers = headers
|
||||
self.content = content
|
||||
self.truncated = truncated
|
||||
self.declared_bytes = declared_bytes
|
||||
self.encoding = encoding
|
||||
self.url = url
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.content.decode(self.encoding or "utf-8", errors="replace")
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
request = httpx.Request("GET", self.url)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"HTTP {self.status_code} for {self.url}",
|
||||
request=request,
|
||||
response=httpx.Response(self.status_code, request=request),
|
||||
)
|
||||
|
||||
|
||||
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5,
|
||||
max_bytes: int = None) -> "_CappedFetch":
|
||||
"""Capped streaming GET with SSRF-guarded, DNS-pinned manual redirects.
|
||||
|
||||
Each hop is resolved once, validated as public, and then the actual TCP
|
||||
connection is pinned to that resolved IP. The request URL is left unchanged
|
||||
so Host and TLS SNI keep the original hostname.
|
||||
"""
|
||||
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
||||
current = url
|
||||
for _ in range(max_redirects + 1):
|
||||
ips = _resolve_public_ips(current)
|
||||
|
||||
# Force identity transfer-encoding. With gzip/deflate the wire bytes
|
||||
# and Content-Length can be a small fraction of the decoded body, so a
|
||||
# tiny compressed response could pass the hard-cap preflight and then
|
||||
# expand past the ceiling in one decoded chunk before the streamed cap
|
||||
# below can slice it.
|
||||
req_headers = dict(headers or {})
|
||||
req_headers["Accept-Encoding"] = "identity"
|
||||
|
||||
with httpx.Client(
|
||||
headers=req_headers,
|
||||
timeout=timeout,
|
||||
follow_redirects=False,
|
||||
transport=_PinnedTransport(ips[0]),
|
||||
) as client:
|
||||
with client.stream("GET", current) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
return _CappedFetch(response.status_code, response.headers, b"",
|
||||
False, None, response.encoding, str(response.url))
|
||||
current = urljoin(str(response.url), location)
|
||||
continue
|
||||
|
||||
# A server can ignore the identity request and still return a
|
||||
# compressed body; httpx.iter_bytes would then decode it, and a
|
||||
# tiny gzip can balloon into one decoded chunk far past the cap.
|
||||
# Refuse compressed Content-Encoding so the streamed cap stays
|
||||
# a real memory bound.
|
||||
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
||||
if enc and enc != "identity":
|
||||
raise httpx.RequestError(
|
||||
f"Refusing compressed response (Content-Encoding: {enc}) after "
|
||||
"requesting identity: cannot bound decoded body size",
|
||||
request=httpx.Request("GET", current),
|
||||
)
|
||||
|
||||
declared = None
|
||||
raw_len = response.headers.get("content-length")
|
||||
if raw_len and raw_len.isdigit():
|
||||
declared = int(raw_len)
|
||||
|
||||
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
||||
raise BodyTooLargeError(current, declared)
|
||||
|
||||
chunks = []
|
||||
read = 0
|
||||
truncated = False
|
||||
for chunk in response.iter_bytes():
|
||||
read += len(chunk)
|
||||
if read > cap:
|
||||
keep = cap - (read - len(chunk))
|
||||
if keep > 0:
|
||||
chunks.append(chunk[:keep])
|
||||
truncated = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
|
||||
return _CappedFetch(response.status_code, response.headers,
|
||||
b"".join(chunks), truncated, declared,
|
||||
response.encoding, str(response.url))
|
||||
|
||||
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
|
||||
|
||||
# PDF extraction (optional dependency)
|
||||
try:
|
||||
from pdfminer.high_level import extract_text as pdf_extract_text
|
||||
|
||||
+181
-561
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
+3
-35
@@ -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}")
|
||||
|
||||
@@ -462,38 +459,12 @@ class ChatProcessor:
|
||||
skip_url_fetch = len(message) > 2000 or len(non_yt_urls) > 3
|
||||
if not skip_url_fetch:
|
||||
for url in non_yt_urls:
|
||||
try:
|
||||
result = fetch_webpage_content(url)
|
||||
except Exception:
|
||||
# The URL and exception can both contain signed-query
|
||||
# credentials or response-controlled text. Keep the log
|
||||
# diagnostic stable as well as the model-facing context.
|
||||
logger.warning("Automatic URL fetch failed while building context")
|
||||
result = {"success": False, "error": ""}
|
||||
result = fetch_webpage_content(url)
|
||||
if result.get('success'):
|
||||
content = result.get('content', '')[:10000]
|
||||
preface.append(untrusted_context_message(
|
||||
f"web page: {url}",
|
||||
f"Content from {url}:\n\n{content}",
|
||||
provenance_origin="external",
|
||||
))
|
||||
else:
|
||||
# A failed automatic URL fetch is context too. Never pass
|
||||
# exception text or response-controlled diagnostics back to
|
||||
# the model: reduce the result to a small transport-owned
|
||||
# status and explicitly state that the page was not read.
|
||||
error = str(result.get("error") or "")
|
||||
status = "the page was unavailable"
|
||||
status_match = re.match(r"^HTTP\s+(\d{3})\b", error)
|
||||
if status_match:
|
||||
status = f"the server returned HTTP {status_match.group(1)}"
|
||||
elif error.startswith("TooLarge:"):
|
||||
status = "the response exceeded the fetch size limit"
|
||||
elif error.startswith("Rate limit"):
|
||||
status = "the request was rate limited"
|
||||
preface.append(untrusted_context_message(
|
||||
"web page fetch failure",
|
||||
f"A linked page was not read: {status}.",
|
||||
))
|
||||
|
||||
# Skills index — progressive disclosure. Only injected when the
|
||||
@@ -517,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
|
||||
|
||||
+1
-8
@@ -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}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -290,22 +290,17 @@ def detect_vendor(base_url: Any = "", endpoint_kind: Any = "") -> str:
|
||||
return kind_map[kind]
|
||||
|
||||
parsed = urlparse(compact_str(base_url))
|
||||
host = (parsed.hostname or "").lower().rstrip(".")
|
||||
host = (parsed.hostname or "").lower()
|
||||
port = parsed.port
|
||||
|
||||
def host_matches(domain: str) -> bool:
|
||||
domain = domain.lower().rstrip(".")
|
||||
return host == domain or host.endswith(f".{domain}")
|
||||
|
||||
if host_matches("openrouter.ai"):
|
||||
if host.endswith("openrouter.ai"):
|
||||
return VENDOR_OPENROUTER
|
||||
if host_matches("openai.com"):
|
||||
if host.endswith("openai.com"):
|
||||
return VENDOR_OPENAI
|
||||
if host_matches("anthropic.com"):
|
||||
if host.endswith("anthropic.com"):
|
||||
return VENDOR_ANTHROPIC
|
||||
if host_matches("googleapis.com"):
|
||||
if host.endswith("googleapis.com"):
|
||||
return VENDOR_GOOGLE
|
||||
if host_matches("ollama.com") or port == 11434:
|
||||
if host.endswith("ollama.com") or port == 11434:
|
||||
return VENDOR_OLLAMA
|
||||
if port == 1234:
|
||||
return VENDOR_LMSTUDIO
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
"""SSRF-guarded synchronous HTTP fetching primitives.
|
||||
|
||||
This module owns outbound URL classification, one-resolution-per-hop DNS
|
||||
pinning, redirects, and response-body budgets. It deliberately has no search
|
||||
or content-extraction dependencies so callers outside search can reuse the
|
||||
same transport boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import ssl
|
||||
from typing import Callable, Iterable, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
|
||||
from src.constants import WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_SOFT_MAX_BYTES
|
||||
|
||||
|
||||
_PRIVATE_NETWORKS = (
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
)
|
||||
|
||||
|
||||
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return (
|
||||
addr.is_private
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_reserved
|
||||
or addr.is_multicast
|
||||
or addr.is_unspecified
|
||||
or any(addr in net for net in _PRIVATE_NETWORKS)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for info in infos:
|
||||
try:
|
||||
out.append(ipaddress.ip_address(info[4][0]))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _public_http_url(
|
||||
url: str,
|
||||
*,
|
||||
resolver: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||
) -> bool:
|
||||
resolver = resolver or _resolve_hostname_ips
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
host = (parsed.hostname or "").strip()
|
||||
if not host:
|
||||
return False
|
||||
lower = host.lower()
|
||||
if lower in ("localhost", "metadata", "metadata.google.internal"):
|
||||
return False
|
||||
if lower.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
|
||||
return False
|
||||
try:
|
||||
return not _is_private_address(ipaddress.ip_address(host))
|
||||
except ValueError:
|
||||
pass
|
||||
addrs = resolver(host)
|
||||
return bool(addrs) and not any(_is_private_address(a) for a in addrs)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_public_ips(
|
||||
url: str,
|
||||
*,
|
||||
resolver: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||
) -> list[ipaddress._BaseAddress]:
|
||||
resolver = resolver or _resolve_hostname_ips
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
if host in ("localhost", "metadata", "metadata.google.internal"):
|
||||
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if _is_private_address(ip):
|
||||
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
|
||||
return [ip]
|
||||
except httpx.RequestError:
|
||||
raise
|
||||
except ValueError:
|
||||
pass
|
||||
addrs = resolver(host)
|
||||
if not addrs or any(_is_private_address(a) for a in addrs):
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
return addrs
|
||||
|
||||
|
||||
class _PinnedBackend(httpcore.NetworkBackend):
|
||||
"""Network backend that connects to a pre-resolved IP."""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress):
|
||||
self._ip = str(ip)
|
||||
self._real = httpcore.SyncBackend()
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options=None,
|
||||
):
|
||||
return self._real.connect_tcp(
|
||||
self._ip, port, timeout, local_address, socket_options
|
||||
)
|
||||
|
||||
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
return self._real.sleep(seconds)
|
||||
|
||||
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ProxyError: httpx.ProxyError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedTransport(httpx.BaseTransport):
|
||||
"""Transport that pins every TCP connect to a pre-resolved IP."""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
|
||||
self._pool = httpcore.ConnectionPool(
|
||||
ssl_context=ssl.create_default_context(),
|
||||
http1=True,
|
||||
http2=http2,
|
||||
network_backend=_PinnedBackend(ip),
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
self._pool.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
|
||||
self._pool.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
httpcore_req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
try:
|
||||
httpcore_resp = self._pool.handle_request(httpcore_req)
|
||||
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
|
||||
return httpx.Response(
|
||||
status_code=httpcore_resp.status,
|
||||
headers=httpcore_resp.headers,
|
||||
content=content,
|
||||
extensions=httpcore_resp.extensions,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
|
||||
class BodyTooLargeError(Exception):
|
||||
"""The server declared a body larger than the hard fetch ceiling."""
|
||||
|
||||
def __init__(self, url: str, declared_bytes: int):
|
||||
self.url = url
|
||||
self.declared_bytes = declared_bytes
|
||||
super().__init__(
|
||||
f"response body is {declared_bytes:,} bytes, over the "
|
||||
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
|
||||
)
|
||||
|
||||
|
||||
class _CappedFetch:
|
||||
"""Result of a size-capped streaming GET."""
|
||||
|
||||
__slots__ = (
|
||||
"status_code",
|
||||
"headers",
|
||||
"content",
|
||||
"truncated",
|
||||
"declared_bytes",
|
||||
"encoding",
|
||||
"url",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code,
|
||||
headers,
|
||||
content,
|
||||
truncated,
|
||||
declared_bytes,
|
||||
encoding,
|
||||
url,
|
||||
):
|
||||
self.status_code = status_code
|
||||
self.headers = headers
|
||||
self.content = content
|
||||
self.truncated = truncated
|
||||
self.declared_bytes = declared_bytes
|
||||
self.encoding = encoding
|
||||
self.url = url
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.content.decode(self.encoding or "utf-8", errors="replace")
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
request = httpx.Request("GET", self.url)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"HTTP {self.status_code} for {self.url}",
|
||||
request=request,
|
||||
response=httpx.Response(self.status_code, request=request),
|
||||
)
|
||||
|
||||
|
||||
def _get_public_url(
|
||||
url: str,
|
||||
headers: dict,
|
||||
timeout: int,
|
||||
max_redirects: int = 5,
|
||||
max_bytes: int | None = None,
|
||||
*,
|
||||
resolve_public_ips: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||
transport_factory: Callable[[ipaddress._BaseAddress], httpx.BaseTransport] | None = None,
|
||||
) -> _CappedFetch:
|
||||
"""Capped streaming GET with SSRF-guarded, DNS-pinned redirects."""
|
||||
resolve_public_ips = resolve_public_ips or _resolve_public_ips
|
||||
transport_factory = transport_factory or _PinnedTransport
|
||||
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
||||
current = url
|
||||
for _ in range(max_redirects + 1):
|
||||
ips = resolve_public_ips(current)
|
||||
req_headers = dict(headers or {})
|
||||
req_headers["Accept-Encoding"] = "identity"
|
||||
|
||||
with httpx.Client(
|
||||
headers=req_headers,
|
||||
timeout=timeout,
|
||||
follow_redirects=False,
|
||||
transport=transport_factory(ips[0]),
|
||||
) as client:
|
||||
with client.stream("GET", current) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
return _CappedFetch(
|
||||
response.status_code,
|
||||
response.headers,
|
||||
b"",
|
||||
False,
|
||||
None,
|
||||
response.encoding,
|
||||
str(response.url),
|
||||
)
|
||||
current = urljoin(str(response.url), location)
|
||||
continue
|
||||
|
||||
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
||||
if enc and enc != "identity":
|
||||
raise httpx.RequestError(
|
||||
f"Refusing compressed response (Content-Encoding: {enc}) after "
|
||||
"requesting identity: cannot bound decoded body size",
|
||||
request=httpx.Request("GET", current),
|
||||
)
|
||||
|
||||
declared = None
|
||||
raw_len = response.headers.get("content-length")
|
||||
if raw_len and raw_len.isdigit():
|
||||
declared = int(raw_len)
|
||||
|
||||
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
||||
raise BodyTooLargeError(current, declared)
|
||||
|
||||
chunks = []
|
||||
read = 0
|
||||
truncated = False
|
||||
for chunk in response.iter_bytes():
|
||||
read += len(chunk)
|
||||
if read > cap:
|
||||
keep = cap - (read - len(chunk))
|
||||
if keep > 0:
|
||||
chunks.append(chunk[:keep])
|
||||
truncated = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
|
||||
return _CappedFetch(
|
||||
response.status_code,
|
||||
response.headers,
|
||||
b"".join(chunks),
|
||||
truncated,
|
||||
declared,
|
||||
response.encoding,
|
||||
str(response.url),
|
||||
)
|
||||
|
||||
raise httpx.RequestError(
|
||||
"Too many redirects", request=httpx.Request("GET", current)
|
||||
)
|
||||
@@ -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
@@ -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
-38
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+26
-64
@@ -231,11 +231,23 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- KaTeX and Mermaid are vendored in /static/lib and pulled in by
|
||||
static/js/markdown.js the first time a page actually renders math or a
|
||||
```mermaid fence. They used to load here from cdn.jsdelivr.net on every
|
||||
page load, which cost ~985 KB on the wire, broke offline installs, and
|
||||
announced every session to a third party. -->
|
||||
<!-- KaTeX CSS is loaded with media="print" so it doesn't block render,
|
||||
then flipped to "all" via JS after load. Mermaid init runs once the
|
||||
library finishes loading. Both hooks are wired via addEventListener
|
||||
below (inline onload= attrs are blocked by CSP script-src-attr). -->
|
||||
<link id="katex-css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" media="print">
|
||||
<script async src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js"></script>
|
||||
<script id="mermaid-script" async src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(function(){
|
||||
var k = document.getElementById('katex-css');
|
||||
if (k) k.addEventListener('load', function(){ k.media = 'all'; }, { once: true });
|
||||
var m = document.getElementById('mermaid-script');
|
||||
if (m) m.addEventListener('load', function(){
|
||||
if (window.odysseusInitMermaid) window.odysseusInitMermaid();
|
||||
}, { once: true });
|
||||
})();
|
||||
</script>
|
||||
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
|
||||
the app font and the weight the sidebar and header text render at. They
|
||||
are declared in style.css, so without a hint they are only discovered
|
||||
@@ -246,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">
|
||||
@@ -1409,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>
|
||||
@@ -1518,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">
|
||||
|
||||
@@ -2570,20 +2532,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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+76
-127
@@ -8,18 +8,18 @@
|
||||
import Storage from './storage.js';
|
||||
import uiModule from './ui.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
|
||||
import chatStream from './chatStream.js?v=20260815approvalsave1';
|
||||
import chatRenderer from './chatRenderer.js?v=20260722emailfastindex1';
|
||||
import chatStream from './chatStream.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import presetsModule from './presets.js';
|
||||
import fileHandlerModule from './fileHandler.js';
|
||||
import searchModule from './search.js';
|
||||
import documentModule from './document.js?v=20260815approvalsave1';
|
||||
import * as emailInbox from './emailInbox.js?v=20260815approvalsave1';
|
||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
||||
import * as emailInbox from './emailInbox.js?v=20260722emailfastindex1';
|
||||
import codeRunnerModule from './codeRunner.js';
|
||||
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260815approvalsave1';
|
||||
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260722emailfastindex1';
|
||||
import createResearchSynapse from './researchSynapse.js';
|
||||
import { createStreamRenderer } from './streamingRenderer.js';
|
||||
import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall';
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
inheritModelRouteState,
|
||||
} from './chatModelProvenance.js';
|
||||
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
|
||||
import { loadPanel } from './panels.js';
|
||||
|
||||
const RESEARCH_TIMEOUT_MS = 360000;
|
||||
const DEFAULT_TIMEOUT_MS = 120000;
|
||||
@@ -60,41 +59,6 @@ import { loadPanel } from './panels.js';
|
||||
let _contextHeaderSeq = 0;
|
||||
let _contextHeaderData = null;
|
||||
let _contextHeaderBound = false;
|
||||
let _pendingToolApproval = null;
|
||||
|
||||
function _submitToolApprovalWhenIdle(approvalId, label) {
|
||||
if (
|
||||
!_pendingToolApproval
|
||||
|| _pendingToolApproval.approval_id !== approvalId
|
||||
) return;
|
||||
if (isStreaming || _sendInFlight) {
|
||||
setTimeout(() => _submitToolApprovalWhenIdle(approvalId, label), 120);
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById('message');
|
||||
if (input) {
|
||||
_pendingToolApproval.draft = input.value || '';
|
||||
input.value = label;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
const sendButton = document.querySelector('.send-btn');
|
||||
if (sendButton) sendButton.click();
|
||||
}
|
||||
|
||||
document.addEventListener('odysseus:tool-approval', (event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const decision = String(detail.decision || '').toLowerCase();
|
||||
if (!detail.approval_id || !['approve', 'deny'].includes(decision)) return;
|
||||
_pendingToolApproval = {
|
||||
approval_id: String(detail.approval_id),
|
||||
decision,
|
||||
document_id: String(detail.document_id || ''),
|
||||
};
|
||||
_submitToolApprovalWhenIdle(
|
||||
_pendingToolApproval.approval_id,
|
||||
detail.label || (decision === 'approve' ? 'Allow once' : 'Deny'),
|
||||
);
|
||||
});
|
||||
|
||||
function _fmtContextNumber(n) {
|
||||
const v = Number(n || 0);
|
||||
@@ -1270,7 +1234,6 @@ import { loadPanel } from './panels.js';
|
||||
if (_sendInFlight) return;
|
||||
const _sendPerf = _createChatSendPerf();
|
||||
_sendInFlight = true;
|
||||
const approvalForSend = _pendingToolApproval;
|
||||
_setForegroundChatBusy(true);
|
||||
// Instant visual feedback so the user sees their click was accepted
|
||||
// even before the streaming button state kicks in below.
|
||||
@@ -1285,7 +1248,7 @@ import { loadPanel } from './panels.js';
|
||||
};
|
||||
|
||||
// --- Setup mode: intercept next message (but let slash commands through) ---
|
||||
if (!approvalForSend) {
|
||||
{
|
||||
const el = uiModule.el;
|
||||
const rawMsg = (el('message').value || '').trim();
|
||||
const currentSetupMode = slashCommands.getSetupMode();
|
||||
@@ -1315,7 +1278,7 @@ import { loadPanel } from './panels.js';
|
||||
if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
||||
|
||||
// --- Slash commands: execute directly without AI (no session needed) ---
|
||||
if (!approvalForSend && isCommand(msg.trim())) {
|
||||
if (isCommand(msg.trim())) {
|
||||
const handled = await handleSlashCommand(msg.trim());
|
||||
if (handled) {
|
||||
el('message').value = '';
|
||||
@@ -1442,7 +1405,7 @@ import { loadPanel } from './panels.js';
|
||||
}
|
||||
|
||||
// --- API key guard: warn if message looks like an API key ---
|
||||
if (!approvalForSend && API_KEY_RE.test(msg.trim())) {
|
||||
if (API_KEY_RE.test(msg.trim())) {
|
||||
if (!await window.styledConfirm('This looks like an API key. Sending it to the AI could expose it.\n\nDid you mean to use /setup instead?', { confirmText: 'Send anyway', danger: true })) {
|
||||
_releaseSendFlag();
|
||||
return;
|
||||
@@ -1577,9 +1540,7 @@ import { loadPanel } from './panels.js';
|
||||
if (sessionModule.clearStreamComplete) sessionModule.clearStreamComplete(sessionModule.getCurrentSessionId());
|
||||
|
||||
// Check for document selection context before consuming display override
|
||||
const docSel = !approvalForSend && documentModule
|
||||
? documentModule.getSelectionContext()
|
||||
: null;
|
||||
const docSel = documentModule && documentModule.getSelectionContext();
|
||||
if (docSel) {
|
||||
const sels = Array.isArray(docSel) ? docSel : [docSel];
|
||||
const lineRefs = sels.map(s =>
|
||||
@@ -1599,9 +1560,7 @@ import { loadPanel } from './panels.js';
|
||||
// stuck flag can't silently eat the next turn's recovery budget.
|
||||
if (!skipBubble) { _autoNudges = 0; _autoContinuePending = false; }
|
||||
else if (_autoContinuePending) { _autoContinuePending = false; }
|
||||
const _pendingAttachInfo = !approvalForSend && fileHandlerModule.getPendingCount()
|
||||
? fileHandlerModule.getPendingInfo()
|
||||
: null;
|
||||
const _pendingAttachInfo = fileHandlerModule.getPendingCount() ? fileHandlerModule.getPendingInfo() : null;
|
||||
// Pre-read importable file contents before upload clears pending files
|
||||
const IMPORTABLE_EXT = /\.(txt|py|js|ts|html|htm|css|md|json|csv|yml|yaml|sh|sql|rs|go|java|c|cpp|h|rb|php|xml|jsx|tsx|log|toml|ini|conf|env|vue|svelte|scss|sass|less)$/i;
|
||||
const _importableFiles = [];
|
||||
@@ -1619,7 +1578,7 @@ import { loadPanel } from './panels.js';
|
||||
_userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null);
|
||||
}
|
||||
_sendPerf.mark('user_bubble_visible');
|
||||
messageInput.value = approvalForSend ? (approvalForSend.draft || '') : '';
|
||||
messageInput.value = '';
|
||||
messageInput.style.height = '';
|
||||
messageInput.dispatchEvent(new Event('input'));
|
||||
// Mobile: dismiss the on-screen keyboard after sending. iOS in
|
||||
@@ -1653,15 +1612,13 @@ import { loadPanel } from './panels.js';
|
||||
}
|
||||
|
||||
let ids = [];
|
||||
if (!approvalForSend) {
|
||||
try {
|
||||
_sendPerf.mark('upload_begin');
|
||||
ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() });
|
||||
_sendPerf.mark('upload_done');
|
||||
} catch(e) {
|
||||
console.error('upload failed', e);
|
||||
_sendPerf.mark('upload_failed');
|
||||
}
|
||||
try {
|
||||
_sendPerf.mark('upload_begin');
|
||||
ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() });
|
||||
_sendPerf.mark('upload_done');
|
||||
} catch(e) {
|
||||
console.error('upload failed', e);
|
||||
_sendPerf.mark('upload_failed');
|
||||
}
|
||||
if (_pendingAttachInfo && !ids.length && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) {
|
||||
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
|
||||
@@ -1678,10 +1635,10 @@ import { loadPanel } from './panels.js';
|
||||
// edited OCR text via the server-side .vision cache). Always CONSUME the
|
||||
// slot — even when empty / errored — so the regen ids can't bleed into
|
||||
// an unrelated next message if uploadPending() above had thrown.
|
||||
if (!approvalForSend && _pendingRegenAttachments && _pendingRegenAttachments.length) {
|
||||
if (_pendingRegenAttachments && _pendingRegenAttachments.length) {
|
||||
ids = ids.concat(_pendingRegenAttachments);
|
||||
}
|
||||
if (!approvalForSend) _pendingRegenAttachments = null;
|
||||
_pendingRegenAttachments = null;
|
||||
|
||||
// The optimistic user bubble was rendered before the upload assigned ids,
|
||||
// so image previews couldn't show (the renderer needs att.id). Now that
|
||||
@@ -1762,50 +1719,14 @@ import { loadPanel } from './panels.js';
|
||||
if (activeEmailComposerCtx?.docId) {
|
||||
activeDocIdForSend = activeEmailComposerCtx.docId;
|
||||
}
|
||||
const shouldSaveActiveDoc = !approvalForSend || (
|
||||
approvalForSend.document_id
|
||||
&& approvalForSend.document_id === activeDocIdForSend
|
||||
);
|
||||
if (documentModule && activeDocIdForSend && shouldSaveActiveDoc) {
|
||||
if (documentModule && activeDocIdForSend) {
|
||||
try {
|
||||
_sendPerf.mark('doc_save_begin');
|
||||
const documentSaved = await documentModule.saveDocument({
|
||||
silent: !!approvalForSend,
|
||||
});
|
||||
await documentModule.saveDocument();
|
||||
_sendPerf.mark('doc_save_done');
|
||||
if (approvalForSend && documentSaved === false) {
|
||||
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
|
||||
if (
|
||||
_pendingToolApproval
|
||||
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
|
||||
) {
|
||||
_pendingToolApproval = null;
|
||||
}
|
||||
uiModule.showError && uiModule.showError(
|
||||
'Document could not be saved, so the action was not approved. Reload the chat to retry.'
|
||||
);
|
||||
updateSubmitButton('idle', submitBtn);
|
||||
_releaseSendFlag();
|
||||
return;
|
||||
}
|
||||
} catch(e) {
|
||||
console.warn('doc auto-save failed', e);
|
||||
_sendPerf.mark('doc_save_failed');
|
||||
if (approvalForSend) {
|
||||
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
|
||||
if (
|
||||
_pendingToolApproval
|
||||
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
|
||||
) {
|
||||
_pendingToolApproval = null;
|
||||
}
|
||||
uiModule.showError && uiModule.showError(
|
||||
'Document could not be saved, so the action was not approved. Reload the chat to retry.'
|
||||
);
|
||||
updateSubmitButton('idle', submitBtn);
|
||||
_releaseSendFlag();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,30 +1756,18 @@ import { loadPanel } from './panels.js';
|
||||
const fd = new FormData();
|
||||
fd.append('message', _finalMsgWithInject);
|
||||
fd.append('session', streamSessionId);
|
||||
if (approvalForSend) {
|
||||
fd.append('tool_approval_id', approvalForSend.approval_id);
|
||||
fd.append('tool_approval_decision', approvalForSend.decision);
|
||||
if (
|
||||
_pendingToolApproval
|
||||
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
|
||||
) {
|
||||
_pendingToolApproval = null;
|
||||
}
|
||||
}
|
||||
if (selectedRouteForSend.model) fd.append('selected_model', selectedRouteForSend.model);
|
||||
if (selectedRouteForSend.endpoint_url) fd.append('selected_endpoint_url', selectedRouteForSend.endpoint_url);
|
||||
if (selectedRouteForSend.endpoint_id) fd.append('selected_endpoint_id', selectedRouteForSend.endpoint_id);
|
||||
if (ids.length) fd.append('attachments', JSON.stringify(ids));
|
||||
// Auto-save & send active doc ID so the backend sees latest content
|
||||
if (documentModule && activeDocIdForSend && shouldSaveActiveDoc) {
|
||||
if (!approvalForSend) {
|
||||
try {
|
||||
_sendPerf.mark('doc_silent_save_begin');
|
||||
await documentModule.saveDocument({ silent: true });
|
||||
_sendPerf.mark('doc_silent_save_done');
|
||||
} catch (_e) {
|
||||
_sendPerf.mark('doc_silent_save_failed');
|
||||
}
|
||||
if (documentModule && activeDocIdForSend) {
|
||||
try {
|
||||
_sendPerf.mark('doc_silent_save_begin');
|
||||
await documentModule.saveDocument({ silent: true });
|
||||
_sendPerf.mark('doc_silent_save_done');
|
||||
} catch (_e) {
|
||||
_sendPerf.mark('doc_silent_save_failed');
|
||||
}
|
||||
fd.append('active_doc_id', activeDocIdForSend);
|
||||
}
|
||||
@@ -1912,7 +1821,7 @@ import { loadPanel } from './panels.js';
|
||||
if (isAgentMode) {
|
||||
fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false');
|
||||
}
|
||||
if (!approvalForSend && el('research-toggle').checked) {
|
||||
if (el('research-toggle').checked) {
|
||||
fd.append('use_research', 'true');
|
||||
// Research always runs in chat mode — override agent if set
|
||||
fd.set('mode', 'chat');
|
||||
@@ -2243,6 +2152,9 @@ import { loadPanel } from './panels.js';
|
||||
_roundDisplayProjector.reset();
|
||||
_replyDisplayProjector.reset();
|
||||
_docFenceOpened = false;
|
||||
_docFenceContentStart = -1;
|
||||
_docFenceCandidateStart = -1;
|
||||
_docFenceCandidateMarker = '';
|
||||
}
|
||||
const esc = uiModule.esc;
|
||||
// Remove thinking spinner helper
|
||||
@@ -2332,6 +2244,9 @@ import { loadPanel } from './panels.js';
|
||||
|
||||
// Document streaming state (text-fence detection)
|
||||
let _docFenceOpened = false;
|
||||
let _docFenceContentStart = -1;
|
||||
let _docFenceCandidateStart = -1;
|
||||
let _docFenceCandidateMarker = '';
|
||||
const _thinkingAnalysisGate = createThinkingAnalysisGate({
|
||||
startsWithReasoningPrefix: markdownModule.startsWithReasoningPrefix,
|
||||
});
|
||||
@@ -2926,11 +2841,42 @@ import { loadPanel } from './panels.js';
|
||||
roundText += _delta;
|
||||
_roundDisplayProjector.append(_delta, roundText);
|
||||
|
||||
// Raw model text is not authorization to mutate the editor.
|
||||
// Detect document fences only for chat projection/status; the
|
||||
// server emits doc_stream_* after successful dispatch.
|
||||
if (!_docFenceOpened) {
|
||||
_docFenceOpened = /```(?:create_document|documen(?:t)?)\s*\n/i.test(roundText);
|
||||
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
|
||||
if (!_docFenceOpened && documentModule) {
|
||||
// Only inspect the newly appended boundary. Re-scanning the
|
||||
// full round for every reasoning delta is quadratic even
|
||||
// before thinking normalization runs.
|
||||
const fenceMarkers = ['```document\n', '```documen\n', '```create_document\n'];
|
||||
const fenceScanStart = Math.max(0, roundText.length - _delta.length - 24);
|
||||
if (_docFenceCandidateStart < 0) {
|
||||
for (const candidate of fenceMarkers) {
|
||||
const candidateIdx = roundText.indexOf(candidate, fenceScanStart);
|
||||
if (candidateIdx >= 0 && (_docFenceCandidateStart < 0 || candidateIdx < _docFenceCandidateStart)) {
|
||||
_docFenceCandidateMarker = candidate;
|
||||
_docFenceCandidateStart = candidateIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_docFenceCandidateStart >= 0) {
|
||||
const afterFence = roundText.slice(_docFenceCandidateStart + _docFenceCandidateMarker.length);
|
||||
const fenceLines = afterFence.split('\n');
|
||||
if (fenceLines.length >= 1 && fenceLines[0].trim()) {
|
||||
_docFenceOpened = true;
|
||||
const title = fenceLines[0].trim();
|
||||
// Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py
|
||||
const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini'];
|
||||
const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase());
|
||||
const lang = isLang ? fenceLines[1].trim() : '';
|
||||
_docFenceContentStart = _docFenceCandidateStart + _docFenceCandidateMarker.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0);
|
||||
documentModule.streamDocOpen(title, lang);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_docFenceOpened && _docFenceContentStart > 0 && documentModule) {
|
||||
let raw = roundText.slice(_docFenceContentStart);
|
||||
const closeIdx = raw.indexOf('\n```');
|
||||
if (closeIdx >= 0) raw = raw.slice(0, closeIdx);
|
||||
documentModule.streamDocDelta(raw);
|
||||
}
|
||||
|
||||
// Detect thinking-in-progress:
|
||||
@@ -3850,6 +3796,9 @@ import { loadPanel } from './panels.js';
|
||||
_roundDisplayProjector.reset();
|
||||
_replyDisplayProjector.reset();
|
||||
_docFenceOpened = false;
|
||||
_docFenceContentStart = -1;
|
||||
_docFenceCandidateStart = -1;
|
||||
_docFenceCandidateMarker = '';
|
||||
const box = document.getElementById('chat-history');
|
||||
const newWrap = document.createElement('div');
|
||||
newWrap.className = 'msg msg-ai msg-continuation streaming';
|
||||
@@ -6607,7 +6556,7 @@ import { loadPanel } from './panels.js';
|
||||
// Images → Gallery editor.
|
||||
if (isImage) {
|
||||
try {
|
||||
const gx = await loadPanel('editor');
|
||||
const gx = await import('./galleryEditor.js');
|
||||
if (gx.openEditor) { gx.openEditor(url, id, null, name); return; }
|
||||
} catch (e) { console.warn('gallery open failed', e); }
|
||||
window.open(url, '_blank');
|
||||
|
||||
+11
-58
@@ -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));
|
||||
@@ -1373,7 +1367,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 +1389,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 +1548,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.
|
||||
@@ -2348,7 +2342,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 +2366,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 +2403,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 +2439,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) {
|
||||
@@ -2533,7 +2489,7 @@ 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);
|
||||
}
|
||||
@@ -2541,12 +2497,9 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const toolRounds = Object.keys(toolsByRound).map(Number);
|
||||
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, 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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -15,7 +15,6 @@ let _getPlatform;
|
||||
let _serverByVal;
|
||||
let _isWindows;
|
||||
let _buildEnvPrefix;
|
||||
let _psQuote;
|
||||
let _buildServeCmd;
|
||||
let _detectBackend;
|
||||
let _detectToolParser;
|
||||
@@ -539,7 +538,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
if (srv.downloadDir) payload.local_dir = srv.downloadDir;
|
||||
if (isWin) {
|
||||
if (env === 'venv' && envPath) {
|
||||
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
|
||||
payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (env === 'conda' && envPath) {
|
||||
payload.env_prefix = 'conda activate ' + envPath;
|
||||
}
|
||||
@@ -653,7 +652,6 @@ export function initDownload(shared) {
|
||||
_serverByVal = shared._serverByVal;
|
||||
_isWindows = shared._isWindows;
|
||||
_buildEnvPrefix = shared._buildEnvPrefix;
|
||||
_psQuote = shared._psQuote;
|
||||
_buildServeCmd = shared._buildServeCmd;
|
||||
_detectBackend = shared._detectBackend;
|
||||
_detectToolParser = shared._detectToolParser;
|
||||
|
||||
@@ -338,7 +338,6 @@ let _sshPrefix;
|
||||
let _getPlatform;
|
||||
let _isWindows;
|
||||
let _buildEnvPrefix;
|
||||
let _psQuote;
|
||||
let _loadPresets;
|
||||
let _savePresets;
|
||||
let _copyText;
|
||||
@@ -1972,7 +1971,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
let envPrefix = '';
|
||||
if (_isWindows()) {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
||||
envPrefix = '& ' + (_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
envPrefix = 'conda activate ' + _envState.envPath;
|
||||
}
|
||||
@@ -4403,7 +4402,6 @@ export function initRunning(shared) {
|
||||
_getPlatform = shared._getPlatform;
|
||||
_isWindows = shared._isWindows;
|
||||
_buildEnvPrefix = shared._buildEnvPrefix;
|
||||
_psQuote = shared._psQuote;
|
||||
_loadPresets = shared._loadPresets;
|
||||
_savePresets = shared._savePresets;
|
||||
_copyText = shared._copyText;
|
||||
|
||||
+5
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9738,11 +9736,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
|
||||
container.innerHTML = html;
|
||||
// This container is detached, so the document-scoped flush mdToHtml
|
||||
// schedules never sees it. Typeset the deferred math before html2pdf
|
||||
// rasterises, or the PDF gets raw formula source. renderMath() returns
|
||||
// immediately, without loading KaTeX, when there is nothing pending.
|
||||
await markdownModule.renderMath(container);
|
||||
const baseName = _getExportBaseName();
|
||||
window.html2pdf().set({
|
||||
margin: 10,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
+63
-202
@@ -10,127 +10,6 @@ import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js'
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
// Mermaid and KaTeX are vendored under /static/lib and fetched on first use.
|
||||
// Loading them from <head> cost every session ~985 KB on the wire even though
|
||||
// most chats never contain a diagram or a formula. Both loaders memoise the
|
||||
// *promise* rather than the resolved library, so concurrent callers share one
|
||||
// fetch and a double trigger cannot start two loads. A failed load clears the
|
||||
// memo so the next diagram/formula retries instead of being poisoned forever.
|
||||
const MERMAID_SRC = '/static/lib/mermaid.min.js';
|
||||
const KATEX_SRC = '/static/lib/katex/katex.min.js';
|
||||
const KATEX_CSS = '/static/lib/katex/katex.min.css';
|
||||
// Marks math emitted before KaTeX finished loading; renderMath() swaps these
|
||||
// for typeset output. The source stays as readable text inside the span, so a
|
||||
// load that never completes degrades to plain text rather than to nothing.
|
||||
const MATH_PENDING_CLASS = 'ody-math-pending';
|
||||
|
||||
// KaTeX has no entity syntax: it reads a bare "&" as an alignment marker and
|
||||
// errors out on anything that is not a valid column break, so "a < b" comes
|
||||
// back as a red .katex-error instead of a formula. mdToHtml escapes the whole
|
||||
// string before the math pass, which leaves two spellings of the same
|
||||
// character at the delimiters — a typed "<" arrives as "<", while a typed
|
||||
// "<" arrives as "&lt;" — and both have to reach KaTeX as "<".
|
||||
//
|
||||
// One alternation, longest form first, so nothing this writes is scanned
|
||||
// again. Chained .replace() calls cannot do it: unescaping "&" first lets
|
||||
// the next pass eat the "<" it just produced (the double-unescape CodeQL
|
||||
// flags), and unescaping it last leaves the entity spelling intact and breaks
|
||||
// the render. The code-block pass upstream keeps its chained order on purpose
|
||||
// — Markdown does not decode entities inside code, so "<" there is meant to
|
||||
// stay visible.
|
||||
const MATH_SOURCE_ENTITY_RE = /&(?:lt|gt|amp|quot|#39);|<|>|&/g;
|
||||
const MATH_SOURCE_ENTITIES = {
|
||||
'&lt;': '<',
|
||||
'&gt;': '>',
|
||||
'&amp;': '&',
|
||||
'&quot;': '"',
|
||||
'&#39;': "'",
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
};
|
||||
|
||||
function decodeMathSource(text) {
|
||||
return String(text).replace(MATH_SOURCE_ENTITY_RE, (entity) => MATH_SOURCE_ENTITIES[entity]);
|
||||
}
|
||||
|
||||
let _mermaidPromise = null;
|
||||
let _katexPromise = null;
|
||||
let _mathFlushScheduled = false;
|
||||
|
||||
function _loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.addEventListener('load', () => resolve(), { once: true });
|
||||
script.addEventListener('error', () => reject(new Error('Failed to load ' + src)), { once: true });
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function _loadStylesheet(href) {
|
||||
// Resolves either way: without the stylesheet KaTeX still produces correct
|
||||
// markup, just unstyled, which beats failing the whole math render.
|
||||
return new Promise((resolve) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
link.addEventListener('load', () => resolve(), { once: true });
|
||||
link.addEventListener('error', () => resolve(), { once: true });
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Mermaid on first use and initialize it once.
|
||||
*/
|
||||
export function ensureMermaid() {
|
||||
return (_mermaidPromise ??= _loadScript(MERMAID_SRC)
|
||||
.then(() => {
|
||||
if (!window.mermaid) throw new Error('mermaid global missing after load');
|
||||
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
|
||||
return window.mermaid;
|
||||
})
|
||||
.catch((err) => {
|
||||
_mermaidPromise = null;
|
||||
throw err;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load KaTeX (script + stylesheet) on first use.
|
||||
*/
|
||||
export function ensureKatex() {
|
||||
return (_katexPromise ??= Promise.all([_loadScript(KATEX_SRC), _loadStylesheet(KATEX_CSS)])
|
||||
.then(() => {
|
||||
if (!window.katex) throw new Error('katex global missing after load');
|
||||
return window.katex;
|
||||
})
|
||||
.catch((err) => {
|
||||
_katexPromise = null;
|
||||
throw err;
|
||||
}));
|
||||
}
|
||||
|
||||
// mdToHtml() is synchronous and its callers insert the returned string into the
|
||||
// DOM themselves, so the placeholders are usually not attached yet when this
|
||||
// fires. Loading first and scanning afterwards covers that gap: by the time
|
||||
// KaTeX is in, the caller's innerHTML assignment has long since happened.
|
||||
//
|
||||
// setTimeout, not requestAnimationFrame: this has nothing to do with paint, and
|
||||
// rAF is throttled to a stop in a background tab (and never fires at all in a
|
||||
// headless browser), which would leave math untypeset until the tab is focused.
|
||||
function _scheduleMathFlush() {
|
||||
if (_mathFlushScheduled) return;
|
||||
_mathFlushScheduled = true;
|
||||
setTimeout(() => {
|
||||
_mathFlushScheduled = false;
|
||||
ensureKatex()
|
||||
.then(() => renderMath(document))
|
||||
.catch((e) => console.warn('KaTeX load error:', e));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function safeLinkUrl(rawUrl) {
|
||||
const url = String(rawUrl || '').trim();
|
||||
if (url.startsWith('#')) {
|
||||
@@ -752,45 +631,49 @@ export function mdToHtml(src, opts) {
|
||||
|
||||
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
|
||||
const mathBlocks = [];
|
||||
let sawPendingMath = false;
|
||||
|
||||
// Typeset straight away when KaTeX is already in, otherwise bank the source in
|
||||
// an inert placeholder for renderMath() to swap once the library lands.
|
||||
const pushMath = (math, displayMode) => {
|
||||
const raw = decodeMathSource(math).trim();
|
||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
||||
if (window.katex) {
|
||||
mathBlocks.push(katex.renderToString(raw, { displayMode, throwOnError: false }));
|
||||
} else {
|
||||
sawPendingMath = true;
|
||||
mathBlocks.push(`<span class="${MATH_PENDING_CLASS}" data-display="${displayMode}">${escapeHtml(raw)}</span>`);
|
||||
}
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
// Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.).
|
||||
// Handle before $$/$ so all common delimiters render.
|
||||
s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
|
||||
try { return pushMath(math, true); } catch (e) { return match; }
|
||||
});
|
||||
// Inline math: \( ... \) — GPT-style inline delimiter. Single-line only
|
||||
// ([^\n]) so a stray escaped paren in prose can't swallow across lines.
|
||||
s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => {
|
||||
try { return pushMath(math, false); } catch (e) { return match; }
|
||||
});
|
||||
// Display math: $$...$$
|
||||
s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => {
|
||||
try { return pushMath(math, true); } catch (e) { return match; }
|
||||
});
|
||||
// Inline math: $...$ — single line only, and Pandoc-style delimiter rules so
|
||||
// currency doesn't render as math ("$5 to $10"): the opening $ must be
|
||||
// immediately followed by a non-space, the closing $ must be immediately
|
||||
// preceded by a non-space and not followed by a digit.
|
||||
s = s.replace(/(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)/g, (match, math) => {
|
||||
try { return pushMath(math, false); } catch (e) { return match; }
|
||||
});
|
||||
|
||||
if (sawPendingMath) _scheduleMathFlush();
|
||||
if (window.katex) {
|
||||
// Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.).
|
||||
// Handle before $$/$ so all common delimiters render.
|
||||
s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
|
||||
try {
|
||||
const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
||||
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false }));
|
||||
return placeholder;
|
||||
} catch (e) { return match; }
|
||||
});
|
||||
// Inline math: \( ... \) — GPT-style inline delimiter. Single-line only
|
||||
// ([^\n]) so a stray escaped paren in prose can't swallow across lines.
|
||||
s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => {
|
||||
try {
|
||||
const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
||||
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false }));
|
||||
return placeholder;
|
||||
} catch (e) { return match; }
|
||||
});
|
||||
// Display math: $$...$$
|
||||
s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => {
|
||||
try {
|
||||
const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
||||
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false }));
|
||||
return placeholder;
|
||||
} catch (e) { return match; }
|
||||
});
|
||||
// Inline math: $...$ — single line only, and Pandoc-style delimiter rules so
|
||||
// currency doesn't render as math ("$5 to $10"): the opening $ must be
|
||||
// immediately followed by a non-space, the closing $ must be immediately
|
||||
// preceded by a non-space and not followed by a digit.
|
||||
s = s.replace(/(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)/g, (match, math) => {
|
||||
try {
|
||||
const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
||||
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false }));
|
||||
return placeholder;
|
||||
} catch (e) { return match; }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle pipe tables
|
||||
s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => {
|
||||
@@ -943,47 +826,19 @@ export function renderContent(content) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize any unprocessed Mermaid diagrams in a container (or whole document).
|
||||
* Returns a promise so callers can await the (lazy) library load if they need to.
|
||||
* Initialize any unprocessed Mermaid diagrams in a container (or whole document)
|
||||
*/
|
||||
export function renderMermaid(container) {
|
||||
if (!window.mermaid) return;
|
||||
initMermaid();
|
||||
const target = container || document;
|
||||
if (!target || typeof target.querySelectorAll !== 'function') return Promise.resolve();
|
||||
// Cheap pre-check: no fence on the page means Mermaid is never fetched.
|
||||
if (target.querySelectorAll('pre.mermaid:not([data-processed])').length === 0) return Promise.resolve();
|
||||
return ensureMermaid()
|
||||
.then((mermaid) => {
|
||||
// Re-query after the load: during streaming the renderer replaces the
|
||||
// message body repeatedly, so the nodes seen before the fetch are stale.
|
||||
const nodes = [...target.querySelectorAll('pre.mermaid:not([data-processed])')]
|
||||
.filter((node) => node.isConnected);
|
||||
if (nodes.length === 0) return;
|
||||
return mermaid.run({ nodes });
|
||||
})
|
||||
.catch((e) => { console.warn('Mermaid render error:', e); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Typeset any math that mdToHtml() had to defer because KaTeX was not loaded
|
||||
* yet. Once KaTeX is in, mdToHtml() renders inline and this finds nothing.
|
||||
*/
|
||||
export function renderMath(container) {
|
||||
const target = container || document;
|
||||
if (!target || typeof target.querySelectorAll !== 'function') return Promise.resolve();
|
||||
if (target.querySelectorAll('.' + MATH_PENDING_CLASS).length === 0) return Promise.resolve();
|
||||
return ensureKatex()
|
||||
.then((katex) => {
|
||||
target.querySelectorAll('.' + MATH_PENDING_CLASS).forEach((el) => {
|
||||
const displayMode = el.getAttribute('data-display') === 'true';
|
||||
try {
|
||||
el.outerHTML = katex.renderToString(el.textContent || '', { displayMode, throwOnError: false });
|
||||
} catch (e) {
|
||||
// Leave the source visible — readable, just not typeset.
|
||||
el.classList.remove(MATH_PENDING_CLASS);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((e) => { console.warn('KaTeX render error:', e); });
|
||||
const pending = target.querySelectorAll('pre.mermaid:not([data-processed])');
|
||||
if (pending.length === 0) return;
|
||||
try {
|
||||
window.mermaid.run({ nodes: pending });
|
||||
} catch (e) {
|
||||
console.warn('Mermaid render error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const markdownModule = {
|
||||
@@ -998,14 +853,20 @@ const markdownModule = {
|
||||
extractThinkingBlocks,
|
||||
normalizeThinkingMarkup,
|
||||
startsWithReasoningPrefix,
|
||||
renderMermaid,
|
||||
renderMath,
|
||||
ensureMermaid,
|
||||
ensureKatex
|
||||
renderMermaid
|
||||
};
|
||||
|
||||
export default markdownModule;
|
||||
|
||||
// Mermaid is loaded async so it cannot delay the app shell.
|
||||
function initMermaid() {
|
||||
if (!window.mermaid || window.__odysseusMermaidReady) return;
|
||||
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
|
||||
window.__odysseusMermaidReady = true;
|
||||
}
|
||||
window.odysseusInitMermaid = initMermaid;
|
||||
initMermaid();
|
||||
|
||||
// Persist which thinking sections were expanded across page refreshes.
|
||||
// IDs are render-generated (Date.now-based) so we key by a stable hash of
|
||||
// the inner text content instead — same content reproduces the same hash on
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
+219
-132
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -412,9 +476,12 @@ 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);
|
||||
@@ -469,9 +536,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 +634,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 +712,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 +787,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 +869,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 +1031,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 +1189,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 +1344,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 +1512,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 +1579,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 +1624,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 +2022,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 +2238,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 +2290,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 +2592,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 +2745,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 +5661,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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user