mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 13:52:24 +02:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8842526b | ||
|
|
d0d8edf5d8 | ||
|
|
b4d12932a9 | ||
|
|
85297cee44 | ||
|
|
981652358e | ||
|
|
5c835014ac | ||
|
|
43682d4e2e | ||
|
|
032967af4b | ||
|
|
0e03aea134 | ||
|
|
2a6b09b968 | ||
|
|
1a2d889c33 | ||
|
|
517946d778 | ||
|
|
8cb8b074a4 | ||
|
|
ee252e7cd9 | ||
|
|
0af6a99e81 | ||
|
|
f562bfee01 | ||
|
|
0728b994d8 | ||
|
|
db05175e3e | ||
|
|
e4046aa41f | ||
|
|
71f30fcc9d | ||
|
|
b19d327f03 | ||
|
|
d0bf771f9d | ||
|
|
04b8829fb2 | ||
|
|
895bf896e3 | ||
|
|
cc42f38a89 | ||
|
|
d5514da3ab | ||
|
|
67e08cce1b | ||
|
|
2e2bb5231e | ||
|
|
45fc3938e0 |
+21
-2
@@ -88,8 +88,12 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
|
||||
# LOCALHOST_BYPASS=false
|
||||
|
||||
# Mark session cookies Secure. Set true when Odysseus is served through HTTPS
|
||||
# by a trusted reverse proxy or private access gateway.
|
||||
# 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.
|
||||
# SECURE_COOKIES=true
|
||||
|
||||
# Optional: pre-seed the first admin password during setup.
|
||||
@@ -159,6 +163,21 @@ 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,6 +15,13 @@ 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,6 +26,18 @@ body:
|
||||
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
|
||||
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,6 +28,7 @@ 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,6 +41,14 @@ 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 problems = [];
|
||||
const descriptionProblems = [];
|
||||
|
||||
// 1. Summary must be filled in.
|
||||
if (section('Summary').length < 20) {
|
||||
problems.push('**Summary** is empty or too short — describe what changed and why.');
|
||||
descriptionProblems.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) {
|
||||
problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
|
||||
descriptionProblems.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)) {
|
||||
problems.push('**Type of Change** — check at least one box.');
|
||||
descriptionProblems.push('**Type of Change** — check at least one box.');
|
||||
}
|
||||
|
||||
// 4. Duplicate-search checklist item must be checked.
|
||||
if (!/- \[x\] I searched/i.test(body)) {
|
||||
problems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
|
||||
descriptionProblems.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,7 +53,83 @@ 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) {
|
||||
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").');
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comment ──────────────────────────────────────────────────────────────
|
||||
@@ -62,22 +138,43 @@ module.exports = async ({ github, context, core }) => {
|
||||
});
|
||||
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
|
||||
|
||||
if (problems.length === 0) {
|
||||
if (descriptionProblems.length === 0 && evidenceGaps.length === 0) {
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||
}
|
||||
} else {
|
||||
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'),
|
||||
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(
|
||||
'',
|
||||
'---',
|
||||
'_This comment is deleted automatically once all sections are complete._',
|
||||
].join('\n');
|
||||
'_This comment updates automatically when the description or changed files change._',
|
||||
);
|
||||
const commentBody = commentLines.join('\n');
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
|
||||
@@ -97,34 +194,47 @@ 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 swapLabel(num, add, remove) {
|
||||
if (await labelExists(add)) {
|
||||
async function setLabel(name, wanted) {
|
||||
if (wanted && await labelExists(name)) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] });
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNum, labels: [name] });
|
||||
} 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) throw e;
|
||||
core.warning(`Could not add "${add}" — token lacks label write here; skipping.`);
|
||||
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.`);
|
||||
}
|
||||
} else if (wanted) {
|
||||
core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`);
|
||||
} else {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.`);
|
||||
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.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,7 +5,11 @@ 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]
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
|
||||
concurrency:
|
||||
group: pr-description-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# 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
|
||||
@@ -59,12 +63,14 @@ jobs:
|
||||
|
||||
check-mergeable:
|
||||
name: Flag unmergeable PRs
|
||||
needs: check-description
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
# Skip bots: they open PRs programmatically and have their own process.
|
||||
if: github.event.pull_request.user.type != 'Bot'
|
||||
# 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' }}
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
+10
-2
@@ -65,6 +65,16 @@ Vendored in `static/lib/` and served directly:
|
||||
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
|
||||
| [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)
|
||||
|
||||
@@ -72,8 +82,6 @@ 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 |
|
||||
|
||||
|
||||
@@ -68,11 +68,11 @@ Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=odysseus-dev%2Fodysseus&type=date&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#odysseus-dev/odysseus&type=date&legend=top-left">
|
||||
<picture>
|
||||
<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" />
|
||||
<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" />
|
||||
</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.
|
||||
- Set `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
||||
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Set `SECURE_COOKIES=true` to force it on (for a proxy Odysseus cannot see the scheme of), or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
||||
- Use HTTPS when exposing the app beyond localhost.
|
||||
- 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.
|
||||
|
||||
@@ -784,7 +784,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_routes import setup_task_routes
|
||||
from routes.task.task_routes import setup_task_routes
|
||||
app.include_router(setup_task_routes(task_scheduler))
|
||||
|
||||
from routes.assistant_routes import setup_assistant_routes
|
||||
|
||||
@@ -73,6 +73,10 @@ 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"
|
||||
|
||||
+28
-10
@@ -30,11 +30,20 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
|
||||
"""
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=indent)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=indent)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
# Directly unlink to avoid a check-then-act race condition.
|
||||
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def atomic_write_text(path: str, text: str) -> None:
|
||||
@@ -42,8 +51,17 @@ def atomic_write_text(path: str, text: str) -> None:
|
||||
raise TypeError("atomic_write_text expects a string")
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
# Directly unlink to avoid a check-then-act race condition.
|
||||
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
+51
-1
@@ -8,6 +8,11 @@ These are simple datacontainers. All persistence is handled by SessionManager.
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
||||
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .session_manager import SessionManager
|
||||
|
||||
@@ -31,6 +36,35 @@ set_session_manager = set_session_manager_instance
|
||||
get_session_manager = get_session_manager_instance
|
||||
|
||||
|
||||
def _history_grants_chat_session_approval(
|
||||
history: List["ChatMessage"],
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Return whether this exact chat has a resolved session-scope grant."""
|
||||
|
||||
expected_session = str(session_id or "")
|
||||
if not expected_session:
|
||||
return False
|
||||
for message in reversed(history or []):
|
||||
metadata = getattr(message, "metadata", None)
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
tool_events = metadata.get("tool_events")
|
||||
if not isinstance(tool_events, list):
|
||||
continue
|
||||
for event in reversed(tool_events):
|
||||
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
||||
if not isinstance(ask_user, dict):
|
||||
continue
|
||||
if (
|
||||
ask_user.get("kind") == "tool_approval"
|
||||
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
||||
and str(ask_user.get("session_id") or "") == expected_session
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatMessage:
|
||||
"""A single chat message."""
|
||||
@@ -116,11 +150,27 @@ class Session:
|
||||
the model. Display/history-load paths use the raw ``history`` and are
|
||||
unaffected.
|
||||
"""
|
||||
return [
|
||||
messages = [
|
||||
msg.to_dict()
|
||||
for msg in self.history
|
||||
if (msg.metadata or {}).get("source") != "slash"
|
||||
]
|
||||
if not _history_grants_chat_session_approval(self.history, self.id):
|
||||
return messages
|
||||
|
||||
# Keep the grant close to the latest user request so route-neutral
|
||||
# compaction/trimming preserves it. Copy the metadata instead of
|
||||
# mutating the durable transcript object.
|
||||
for index in range(len(messages) - 1, -1, -1):
|
||||
if messages[index].get("role") != "user":
|
||||
continue
|
||||
message = dict(messages[index])
|
||||
metadata = dict(message.get("metadata") or {})
|
||||
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
|
||||
message["metadata"] = metadata
|
||||
messages[index] = message
|
||||
break
|
||||
return messages
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""Dict-like access for compatibility."""
|
||||
|
||||
+17
-1
@@ -14,6 +14,8 @@ 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
|
||||
@@ -92,14 +94,28 @@ class SessionManager:
|
||||
try:
|
||||
db_sessions = db.query(DbSession).filter(
|
||||
DbSession.archived == False,
|
||||
DbSession.message_count > 0,
|
||||
DbSession.messages.any(),
|
||||
).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:
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
- 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:-false}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||
@@ -75,6 +75,11 @@ 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
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
- 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:-false}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||
@@ -74,6 +74,11 @@ 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
|
||||
|
||||
+6
-1
@@ -38,7 +38,7 @@ services:
|
||||
- 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:-false}
|
||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
||||
@@ -63,6 +63,11 @@ 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
|
||||
|
||||
+30
-8
@@ -441,10 +441,19 @@ 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:
|
||||
@@ -475,7 +484,7 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
|
||||
|
||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
||||
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Use `SECURE_COOKIES=true` to force it on for a proxy whose scheme Odysseus cannot see, or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
||||
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
||||
- 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.
|
||||
@@ -486,6 +495,14 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
|
||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
||||
- 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:
|
||||
|
||||
@@ -494,7 +511,7 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
|
||||
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
||||
4. Keep raw service and model ports internal-only.
|
||||
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true` and `LOCALHOST_BYPASS=false`. Any proxy that forwards `X-Forwarded-Proto: https` gets `Secure` session cookies without configuration, so `SECURE_COOKIES` only needs setting when you want to override that — force it on for a proxy that forwards no scheme at all, or off while you still serve plain HTTP.
|
||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||
|
||||
#### Faster over the network: HTTP/2
|
||||
@@ -582,9 +599,12 @@ Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
|
||||
run the proxy as another container, or on the host pointing at the published
|
||||
port.
|
||||
|
||||
**4. Point Odysseus at the new origin** in `.env`, then restart it:
|
||||
**4. Point Odysseus at the new origin** in `.env`, then restart it.
|
||||
|
||||
A proxy that exposes the HTTPS request scheme to Odysseus needs no `SECURE_COOKIES` setting. Only force it on when the proxy cannot expose that scheme:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
@@ -619,10 +639,12 @@ enable it by right-clicking the column headers.
|
||||
|
||||
Three things bite when moving an existing install behind TLS:
|
||||
|
||||
- Set `SECURE_COOKIES=true` **at the same time** you stop serving plain HTTP,
|
||||
not before. The flag is applied to every login regardless of the scheme the
|
||||
request arrived on, so while an HTTP entrypoint is still reachable the
|
||||
browser will reject the `Secure` cookie there and login will appear to loop.
|
||||
- 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.
|
||||
- `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
|
||||
@@ -675,7 +697,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` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
||||
| `SECURE_COOKIES` | derived from the request scheme | Marks session cookies `Secure` on HTTPS requests. Set true to force it on, false to force it off. |
|
||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||
| `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,6 +163,10 @@ 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 ""
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 - 2022 Knut Sveidqvist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -12,6 +12,16 @@
|
||||
# GPU-accelerated transcription — it's auto-detected, CPU is used otherwise.
|
||||
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.
|
||||
|
||||
+28
-1
@@ -86,6 +86,33 @@ 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"])
|
||||
|
||||
@@ -159,7 +186,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
|
||||
secure=_secure_cookie(request),
|
||||
path="/",
|
||||
)
|
||||
if body.remember:
|
||||
|
||||
+20
-5
@@ -624,6 +624,8 @@ async def build_chat_context(
|
||||
agent_mode: bool = False,
|
||||
allow_tool_preprocessing: bool = True,
|
||||
defer_context_shaping: bool = False,
|
||||
continuation_context_message: str | None = None,
|
||||
persist_user_message: bool = True,
|
||||
) -> ChatContext:
|
||||
"""Build the full context (preface + messages) for an LLM call.
|
||||
|
||||
@@ -647,14 +649,14 @@ async def build_chat_context(
|
||||
# Add user message to history. Nobody/incognito uses a request-local
|
||||
# transcript store instead of session history so stale saved chats cannot
|
||||
# bleed into context and the turn is not persisted.
|
||||
if incognito:
|
||||
if persist_user_message and incognito:
|
||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
||||
else:
|
||||
elif persist_user_message:
|
||||
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
||||
|
||||
# Fire events
|
||||
if not incognito:
|
||||
if persist_user_message and not incognito:
|
||||
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
||||
|
||||
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
||||
@@ -666,7 +668,12 @@ async def build_chat_context(
|
||||
getattr(chat_handler, "upload_handler", None),
|
||||
getattr(sess, "owner", None),
|
||||
)
|
||||
casual_low_signal = _is_casual_low_signal(message)
|
||||
context_message = (
|
||||
str(continuation_context_message).strip()
|
||||
if continuation_context_message
|
||||
else message
|
||||
)
|
||||
casual_low_signal = _is_casual_low_signal(context_message)
|
||||
|
||||
# Memory enabled?
|
||||
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
||||
@@ -703,7 +710,15 @@ async def build_chat_context(
|
||||
# Build context preface
|
||||
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
||||
# the sync path uses text_for_context.
|
||||
_ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context
|
||||
_ctx_msg = (
|
||||
context_message
|
||||
if continuation_context_message
|
||||
else (
|
||||
preprocessed.enhanced_message
|
||||
if use_enhanced_message
|
||||
else preprocessed.text_for_context
|
||||
)
|
||||
)
|
||||
_preface_kwargs = dict(
|
||||
message=_ctx_msg,
|
||||
session=sess,
|
||||
|
||||
+140
-41
@@ -89,6 +89,65 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||
"""Persist a consumed approval decision on its existing tool event."""
|
||||
|
||||
approval_key = str(approval_id or "")
|
||||
normalized_decision = str(decision or "").strip().lower()
|
||||
if not approval_key or normalized_decision not in {"approve", "approve_task", "deny"}:
|
||||
return False
|
||||
|
||||
message_id = None
|
||||
resolved_metadata = None
|
||||
for item in reversed(getattr(sess, "history", []) or []):
|
||||
metadata = getattr(item, "metadata", None)
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
tool_events = metadata.get("tool_events")
|
||||
if not isinstance(tool_events, list):
|
||||
continue
|
||||
for event in reversed(tool_events):
|
||||
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
||||
if not isinstance(ask_user, dict):
|
||||
continue
|
||||
if str(ask_user.get("approval_id") or "") != approval_key:
|
||||
continue
|
||||
ask_user["resolved"] = normalized_decision
|
||||
message_id = metadata.get("_db_id")
|
||||
resolved_metadata = {
|
||||
key: value for key, value in metadata.items() if key != "_db_id"
|
||||
}
|
||||
break
|
||||
if resolved_metadata is not None:
|
||||
break
|
||||
|
||||
if resolved_metadata is None or not message_id:
|
||||
return False
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_message = db.query(DBChatMessage).filter(
|
||||
DBChatMessage.id == message_id,
|
||||
DBChatMessage.session_id == str(getattr(sess, "id", "")),
|
||||
).first()
|
||||
if db_message is None:
|
||||
return False
|
||||
db_message.meta_data = json.dumps(resolved_metadata)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to persist tool approval resolution")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _tool_approval_resolution_stream(decision: str) -> AsyncGenerator[str, None]:
|
||||
yield f"data: {json.dumps({'type': 'tool_approval_resolved', 'decision': decision})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def _chat_candidate_request_factory(
|
||||
messages,
|
||||
fallback_context_length: int = 0,
|
||||
@@ -917,6 +976,7 @@ def setup_chat_routes(
|
||||
exact_tool_approval = None
|
||||
pending_tool_approval = None
|
||||
retired_tool_approval_taint = False
|
||||
external_untrusted_context_seen = False
|
||||
tool_approval_continuation = False
|
||||
# Workspace: confine the agent's file/shell tools to this folder.
|
||||
workspace, workspace_rejected = _resolve_request_workspace(
|
||||
@@ -1050,14 +1110,14 @@ def setup_chat_routes(
|
||||
)
|
||||
|
||||
try:
|
||||
# Attachment-only sends: skip the message-required check when the
|
||||
# user has attached one or more files (the attachment IS the action).
|
||||
# Attachment-only sends and approval controls may omit message text.
|
||||
_has_atts = (
|
||||
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
|
||||
or bool(form_data.get("attachments"))
|
||||
)
|
||||
message, session = coerce_message_and_session(
|
||||
body, message, session, session_manager, allow_empty=_has_atts,
|
||||
body, message, session, session_manager,
|
||||
allow_empty=(_has_atts or bool(tool_approval_id)),
|
||||
)
|
||||
# Verify ownership AFTER coerce (which may resolve a default session)
|
||||
# but BEFORE loading. Prevents cross-user session hijack.
|
||||
@@ -1076,8 +1136,14 @@ def setup_chat_routes(
|
||||
409,
|
||||
"This tool approval is invalid, expired, or belongs to another thread.",
|
||||
)
|
||||
pending_taint = bool(
|
||||
pending_tool_approval.external_untrusted_context_seen
|
||||
)
|
||||
external_untrusted_context_seen = (
|
||||
external_untrusted_context_seen or pending_taint
|
||||
)
|
||||
decision = str(tool_approval_decision or "").strip().lower()
|
||||
if decision not in {"approve", "deny"}:
|
||||
if decision not in {"approve", "approve_task", "deny"}:
|
||||
raise HTTPException(400, "Invalid tool approval decision.")
|
||||
if plan_mode:
|
||||
raise HTTPException(
|
||||
@@ -1091,37 +1157,46 @@ def setup_chat_routes(
|
||||
session_id=session,
|
||||
)
|
||||
tool_approval_continuation = True
|
||||
if decision == "approve" and exact_tool_approval is None:
|
||||
if (
|
||||
decision in {"approve", "approve_task"}
|
||||
and exact_tool_approval is None
|
||||
):
|
||||
raise HTTPException(
|
||||
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."
|
||||
if not _mark_tool_approval_resolved(
|
||||
sess,
|
||||
tool_approval_id,
|
||||
decision,
|
||||
):
|
||||
logger.warning(
|
||||
"Tool approval %s was consumed but its persisted card could not be marked resolved",
|
||||
tool_approval_id,
|
||||
)
|
||||
# The sealed server record, not mutable composer state,
|
||||
# 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."
|
||||
if decision == "deny":
|
||||
return StreamingResponse(
|
||||
_tool_approval_resolution_stream(decision),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
# Approval is a control-plane continuation, not a new user turn.
|
||||
# Reuse the sealed interrupted request only for internal context,
|
||||
# retrieval, and policy reconstruction; never persist or display it.
|
||||
message = pending_tool_approval.continuation_query
|
||||
# The sealed server record, not mutable composer state,
|
||||
# restores the original action workspace.
|
||||
workspace = pending_tool_approval.workspace or None
|
||||
workspace_rejected = None
|
||||
if pending_tool_approval.document_id:
|
||||
active_doc_id = pending_tool_approval.document_id
|
||||
# Restore only the coarse request toggle needed by the exact
|
||||
# sealed action. Current privilege, global-disable, incognito,
|
||||
# compare, and tool-policy gates still run.
|
||||
if pending_tool_approval.tool_name == "bash":
|
||||
allow_bash = "true"
|
||||
if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
|
||||
allow_web_search = "true"
|
||||
_search_enabled = True
|
||||
chat_mode = "agent"
|
||||
else:
|
||||
# A normal user message supersedes the card that was waiting
|
||||
@@ -1132,6 +1207,9 @@ def setup_chat_routes(
|
||||
owner=owner,
|
||||
session_id=session,
|
||||
)
|
||||
external_untrusted_context_seen = (
|
||||
external_untrusted_context_seen or retired_tool_approval_taint
|
||||
)
|
||||
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
@@ -1261,6 +1339,14 @@ def setup_chat_routes(
|
||||
agent_mode=(chat_mode == "agent"),
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
defer_context_shaping=foreground_policy.enabled,
|
||||
continuation_context_message=(
|
||||
pending_tool_approval.continuation_query
|
||||
if exact_tool_approval
|
||||
and pending_tool_approval
|
||||
and pending_tool_approval.continuation_query
|
||||
else None
|
||||
),
|
||||
persist_user_message=not tool_approval_continuation,
|
||||
)
|
||||
|
||||
_research_flags = {"do": do_research} # Mutable container for generator scope
|
||||
@@ -1663,7 +1749,11 @@ def setup_chat_routes(
|
||||
if foreground_policy.enabled
|
||||
else ctx.messages
|
||||
)
|
||||
messages = _ensure_current_request_is_latest_user(context_source, message)
|
||||
messages = (
|
||||
list(context_source)
|
||||
if tool_approval_continuation
|
||||
else _ensure_current_request_is_latest_user(context_source, message)
|
||||
)
|
||||
|
||||
# Auto-compact notification
|
||||
if ctx.was_compacted:
|
||||
@@ -2134,7 +2224,10 @@ def setup_chat_routes(
|
||||
incognito=incognito, compare_mode=compare_mode,
|
||||
character_name=ctx.preset.character_name,
|
||||
owner=_user,
|
||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||
allow_background_extraction=(
|
||||
not tool_policy.block_all_tool_calls
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
)
|
||||
_stream_set(session, status="done")
|
||||
yield chunk
|
||||
@@ -2223,17 +2316,17 @@ def setup_chat_routes(
|
||||
plan_mode=plan_mode,
|
||||
approved_plan=approved_plan or None,
|
||||
workspace=workspace or None,
|
||||
relevant_tools=(
|
||||
set(pending_tool_approval.selected_tools)
|
||||
if exact_tool_approval
|
||||
and pending_tool_approval
|
||||
and pending_tool_approval.selected_tools
|
||||
else None
|
||||
),
|
||||
forced_tools=_forced_tools,
|
||||
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
|
||||
)
|
||||
),
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
exact_approval=exact_tool_approval,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
@@ -2401,8 +2494,14 @@ def setup_chat_routes(
|
||||
agent_tool_calls=_agent_tool_calls,
|
||||
skills_manager=skills_manager,
|
||||
owner=_user,
|
||||
extract_skills=user_requested_agent,
|
||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||
extract_skills=(
|
||||
user_requested_agent
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
allow_background_extraction=(
|
||||
not tool_policy.block_all_tool_calls
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
)
|
||||
_stream_set(session, status="done")
|
||||
yield chunk
|
||||
|
||||
@@ -1204,6 +1204,41 @@ 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_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||
_safe_env_prefix, _local_windows_bash_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
|
||||
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(req.env_prefix))
|
||||
lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else 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(req.env_prefix))
|
||||
runner_lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
|
||||
else:
|
||||
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, host, redirect_uri))
|
||||
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, redirect_uri))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -612,15 +612,13 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
def _oauth_authorize_page(
|
||||
auth_url: str,
|
||||
server_id: str,
|
||||
host: str,
|
||||
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
|
||||
redirect_uri: str,
|
||||
) -> str:
|
||||
"""Page with Google sign-in link and URL paste-back form for remote access."""
|
||||
# Escape values interpolated into the page: `host` comes from the request
|
||||
# Host header and `server_id` from the OAuth state — neither is trusted.
|
||||
# Escape values interpolated into the page: `server_id` comes from the OAuth
|
||||
# state and is not 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>
|
||||
@@ -664,7 +662,15 @@ 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>
|
||||
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
|
||||
<!-- 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}">
|
||||
<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. 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.
|
||||
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.
|
||||
"""
|
||||
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) if _hidden_model_ids(ep) else []
|
||||
pinned = _legacy_visible_api_models(ep)
|
||||
return pinned, pinned
|
||||
return _visible_models(
|
||||
_cached_model_ids(ep),
|
||||
@@ -2342,9 +2342,7 @@ 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 = _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 = _picker_models_for_endpoint(ep, base, kind)
|
||||
pinned_set = set(pinned)
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1603,6 +1603,9 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
decision=decision,
|
||||
owner=user,
|
||||
session_id=None,
|
||||
# The button here says "Allow once" and there is no chat to carry a
|
||||
# scope into, so the gate must re-arm behind the sealed action.
|
||||
allow_continuation=False,
|
||||
)
|
||||
|
||||
if decision == "approve" and exact_approval is None:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Task route domain package (slice 2p, #4082/#4071).
|
||||
|
||||
Contains task_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/task_routes.py re-exports from here.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
+14
-1177
File diff suppressed because it is too large
Load Diff
@@ -327,7 +327,12 @@ def list_models():
|
||||
|
||||
@app.post("/v1/images/generations")
|
||||
def generate(req: ImageRequest):
|
||||
model = req.model or _args.model
|
||||
# The served model is the one this process was launched with. `req.model`
|
||||
# is accepted for OpenAI wire compatibility and ignored, matching
|
||||
# scripts/diffusion_server.py: honouring it would let a caller point the
|
||||
# generator at any local directory or Hugging Face repo, and the HiDream
|
||||
# branch runs a python script from inside that directory.
|
||||
model = _args.model
|
||||
width, height = _size(req.size)
|
||||
out_images = []
|
||||
count = max(1, min(int(req.n or 1), 4))
|
||||
@@ -393,7 +398,7 @@ async def edit_image(
|
||||
size: str = Form("1024x1024"),
|
||||
response_format: str = Form("b64_json"),
|
||||
):
|
||||
active_model = model or _args.model
|
||||
active_model = _args.model # pinned; see generate()
|
||||
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
||||
image_raw = await image.read()
|
||||
mask_raw = await mask.read() if mask is not None else None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""odysseus-webhook — shell wrapper for scheduled-task webhook tokens.
|
||||
|
||||
Tasks in the scheduled-task system can carry a `webhook_token`. Any
|
||||
HTTP POST to `/api/webhook/<token>` fires the task. This CLI lists,
|
||||
HTTP POST to `/api/tasks/<task-id>/webhook/<token>` fires the task. This CLI lists,
|
||||
rotates, and revokes those tokens.
|
||||
|
||||
odysseus-webhook list # tasks that have a token
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
@@ -53,6 +54,14 @@ def _summary(t: "ScheduledTask", reveal: bool = False) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _task_webhook_url(base: str, task_id: str, token: str) -> str:
|
||||
"""Build the live task-route URL without leaking ids into path syntax."""
|
||||
root = (base or "http://localhost:7000").rstrip("/")
|
||||
task_part = quote(str(task_id), safe="")
|
||||
token_part = quote(str(token), safe="")
|
||||
return f"{root}/api/tasks/{task_part}/webhook/{token_part}"
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -109,8 +118,7 @@ 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)")
|
||||
base = (args.base or "http://localhost:7000").rstrip("/")
|
||||
url = f"{base}/api/webhook/{t.webhook_token}"
|
||||
url = _task_webhook_url(args.base, t.id, t.webhook_token)
|
||||
emit({
|
||||
"task_id": t.id,
|
||||
"name": t.name,
|
||||
|
||||
+41
-11
@@ -50,16 +50,46 @@ class DocsService:
|
||||
List of DocChunk objects
|
||||
"""
|
||||
results = self.rag.search(query, k=top_k)
|
||||
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"),
|
||||
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,
|
||||
)
|
||||
)
|
||||
for r in results
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
|
||||
return chunks
|
||||
|
||||
async def index(self, directory: str) -> IndexResult:
|
||||
"""
|
||||
@@ -73,8 +103,8 @@ class DocsService:
|
||||
"""
|
||||
result = self.rag.index_personal_documents(directory)
|
||||
return IndexResult(
|
||||
indexed=result.get("indexed", 0),
|
||||
failed=result.get("failed", 0),
|
||||
indexed=result.get("indexed_count", result.get("indexed", 0)),
|
||||
failed=result.get("failed_count", result.get("failed", 0)),
|
||||
errors=result.get("errors", []),
|
||||
)
|
||||
|
||||
|
||||
+31
-331
@@ -2,22 +2,18 @@
|
||||
|
||||
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 Iterable, List, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from typing import List
|
||||
|
||||
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 (
|
||||
@@ -29,336 +25,40 @@ from .cache import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_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):
|
||||
return _outbound_fetch._is_private_address(addr)
|
||||
|
||||
|
||||
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):
|
||||
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 _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
|
||||
|
||||
+27
-5
@@ -3081,10 +3081,17 @@ def _append_tool_results(
|
||||
messages.append(result_message)
|
||||
else:
|
||||
tool_output_text = "\n\n".join(tool_results)
|
||||
msg = {"role": "assistant", "content": round_response}
|
||||
if round_reasoning:
|
||||
msg["reasoning_content"] = round_reasoning
|
||||
messages.append(msg)
|
||||
# An approved-action replay injects the sealed tool result with no
|
||||
# assistant prose for that round, which used to append an assistant turn
|
||||
# whose content was "". Anthropic's Messages API rejects a non-final
|
||||
# assistant message with empty content (HTTP 400), so the resumed turn
|
||||
# died before the model saw the result. A turn carrying neither prose nor
|
||||
# reasoning has nothing to say to any provider, so skip it entirely.
|
||||
if round_response.strip() or round_reasoning:
|
||||
msg = {"role": "assistant", "content": round_response}
|
||||
if round_reasoning:
|
||||
msg["reasoning_content"] = round_reasoning
|
||||
messages.append(msg)
|
||||
# Tool output (shell/python stdout, file reads, fetched pages, email
|
||||
# bodies, MCP results) is sourced from outside the server. Wrap it as
|
||||
# untrusted data so prompt-injection inside a tool result is treated as
|
||||
@@ -3460,7 +3467,10 @@ async def stream_agent_loop(
|
||||
and exact_approval.pending.external_untrusted_context_seen
|
||||
)
|
||||
or messages_contain_external_untrusted_context(messages)
|
||||
)
|
||||
),
|
||||
approval_gate_bypassed=bool(
|
||||
exact_approval and exact_approval.allow_remaining_actions
|
||||
),
|
||||
)
|
||||
mcp_mgr = get_mcp_manager()
|
||||
prep_timings: Dict[str, float] = {}
|
||||
@@ -5698,6 +5708,16 @@ async def stream_agent_loop(
|
||||
"policy": "exact_tool_approval_target",
|
||||
}
|
||||
else:
|
||||
# The approval click becomes a synthetic user turn. Seal the
|
||||
# actual server-selected candidates now so that continuation
|
||||
# does not lose memory, skills, MCP, documents, or other
|
||||
# ToolIndex/RAG-selected tools by classifying that synthetic text.
|
||||
approval_selected_tools = set(_relevant_tools or ())
|
||||
approval_selected_tools.update(
|
||||
name for name in _tool_names_sent if name
|
||||
)
|
||||
approval_selected_tools.add(block.tool_type)
|
||||
approval_selected_tools.difference_update(disabled_tools)
|
||||
pending_approval = tool_approval_store.create(
|
||||
owner=owner,
|
||||
session_id=session_id,
|
||||
@@ -5725,6 +5745,8 @@ async def stream_agent_loop(
|
||||
external_untrusted_context_seen=(
|
||||
run_security.external_untrusted_context_seen
|
||||
),
|
||||
selected_tools=approval_selected_tools,
|
||||
continuation_query=_retrieval_query or _last_user,
|
||||
capabilities=capabilities_for_action(
|
||||
block.tool_type,
|
||||
block.content,
|
||||
|
||||
+15
-1
@@ -810,13 +810,27 @@ 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."""
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
|
||||
+26
-1
@@ -462,7 +462,14 @@ 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:
|
||||
result = fetch_webpage_content(url)
|
||||
try:
|
||||
result = fetch_webpage_content(url)
|
||||
except Exception:
|
||||
# The URL and exception can both contain signed-query
|
||||
# credentials or response-controlled text. Keep the log
|
||||
# diagnostic stable as well as the model-facing context.
|
||||
logger.warning("Automatic URL fetch failed while building context")
|
||||
result = {"success": False, "error": ""}
|
||||
if result.get('success'):
|
||||
content = result.get('content', '')[:10000]
|
||||
preface.append(untrusted_context_message(
|
||||
@@ -470,6 +477,24 @@ class ChatProcessor:
|
||||
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
|
||||
# model has the `manage_skills` tool available (agent_mode), and
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from src.runtime_paths import get_app_root, get_default_data_dir
|
||||
|
||||
APP_VERSION = "1.0.2"
|
||||
APP_VERSION = "1.0.3"
|
||||
|
||||
# Base paths
|
||||
BASE_DIR = os.path.join(get_app_root(), "")
|
||||
|
||||
+24
-10
@@ -15,18 +15,32 @@ 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 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("/")
|
||||
# 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()
|
||||
REDIRECT_URI = f"{_REDIRECT_BASE}/api/mcp/oauth/callback"
|
||||
|
||||
# How long the background connect waits for the user to authorize before giving up.
|
||||
|
||||
@@ -290,17 +290,22 @@ 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()
|
||||
host = (parsed.hostname or "").lower().rstrip(".")
|
||||
port = parsed.port
|
||||
if host.endswith("openrouter.ai"):
|
||||
|
||||
def host_matches(domain: str) -> bool:
|
||||
domain = domain.lower().rstrip(".")
|
||||
return host == domain or host.endswith(f".{domain}")
|
||||
|
||||
if host_matches("openrouter.ai"):
|
||||
return VENDOR_OPENROUTER
|
||||
if host.endswith("openai.com"):
|
||||
if host_matches("openai.com"):
|
||||
return VENDOR_OPENAI
|
||||
if host.endswith("anthropic.com"):
|
||||
if host_matches("anthropic.com"):
|
||||
return VENDOR_ANTHROPIC
|
||||
if host.endswith("googleapis.com"):
|
||||
if host_matches("googleapis.com"):
|
||||
return VENDOR_GOOGLE
|
||||
if host.endswith("ollama.com") or port == 11434:
|
||||
if host_matches("ollama.com") or port == 11434:
|
||||
return VENDOR_OLLAMA
|
||||
if port == 1234:
|
||||
return VENDOR_LMSTUDIO
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""SSRF-guarded synchronous HTTP fetching primitives.
|
||||
|
||||
This module owns outbound URL classification, one-resolution-per-hop DNS
|
||||
pinning, redirects, and response-body budgets. It deliberately has no search
|
||||
or content-extraction dependencies so callers outside search can reuse the
|
||||
same transport boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import ssl
|
||||
from typing import Callable, Iterable, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
|
||||
from src.constants import WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_SOFT_MAX_BYTES
|
||||
|
||||
|
||||
_PRIVATE_NETWORKS = (
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
)
|
||||
|
||||
|
||||
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return (
|
||||
addr.is_private
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_reserved
|
||||
or addr.is_multicast
|
||||
or addr.is_unspecified
|
||||
or any(addr in net for net in _PRIVATE_NETWORKS)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for info in infos:
|
||||
try:
|
||||
out.append(ipaddress.ip_address(info[4][0]))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _public_http_url(
|
||||
url: str,
|
||||
*,
|
||||
resolver: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||
) -> bool:
|
||||
resolver = resolver or _resolve_hostname_ips
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
host = (parsed.hostname or "").strip()
|
||||
if not host:
|
||||
return False
|
||||
lower = host.lower()
|
||||
if lower in ("localhost", "metadata", "metadata.google.internal"):
|
||||
return False
|
||||
if lower.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
|
||||
return False
|
||||
try:
|
||||
return not _is_private_address(ipaddress.ip_address(host))
|
||||
except ValueError:
|
||||
pass
|
||||
addrs = resolver(host)
|
||||
return bool(addrs) and not any(_is_private_address(a) for a in addrs)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_public_ips(
|
||||
url: str,
|
||||
*,
|
||||
resolver: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||
) -> list[ipaddress._BaseAddress]:
|
||||
resolver = resolver or _resolve_hostname_ips
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
if host in ("localhost", "metadata", "metadata.google.internal"):
|
||||
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if _is_private_address(ip):
|
||||
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
|
||||
return [ip]
|
||||
except httpx.RequestError:
|
||||
raise
|
||||
except ValueError:
|
||||
pass
|
||||
addrs = resolver(host)
|
||||
if not addrs or any(_is_private_address(a) for a in addrs):
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
return addrs
|
||||
|
||||
|
||||
class _PinnedBackend(httpcore.NetworkBackend):
|
||||
"""Network backend that connects to a pre-resolved IP."""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress):
|
||||
self._ip = str(ip)
|
||||
self._real = httpcore.SyncBackend()
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options=None,
|
||||
):
|
||||
return self._real.connect_tcp(
|
||||
self._ip, port, timeout, local_address, socket_options
|
||||
)
|
||||
|
||||
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
return self._real.sleep(seconds)
|
||||
|
||||
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ProxyError: httpx.ProxyError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedTransport(httpx.BaseTransport):
|
||||
"""Transport that pins every TCP connect to a pre-resolved IP."""
|
||||
|
||||
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
|
||||
self._pool = httpcore.ConnectionPool(
|
||||
ssl_context=ssl.create_default_context(),
|
||||
http1=True,
|
||||
http2=http2,
|
||||
network_backend=_PinnedBackend(ip),
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
self._pool.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
|
||||
self._pool.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
httpcore_req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
try:
|
||||
httpcore_resp = self._pool.handle_request(httpcore_req)
|
||||
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
|
||||
return httpx.Response(
|
||||
status_code=httpcore_resp.status,
|
||||
headers=httpcore_resp.headers,
|
||||
content=content,
|
||||
extensions=httpcore_resp.extensions,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
|
||||
class BodyTooLargeError(Exception):
|
||||
"""The server declared a body larger than the hard fetch ceiling."""
|
||||
|
||||
def __init__(self, url: str, declared_bytes: int):
|
||||
self.url = url
|
||||
self.declared_bytes = declared_bytes
|
||||
super().__init__(
|
||||
f"response body is {declared_bytes:,} bytes, over the "
|
||||
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
|
||||
)
|
||||
|
||||
|
||||
class _CappedFetch:
|
||||
"""Result of a size-capped streaming GET."""
|
||||
|
||||
__slots__ = (
|
||||
"status_code",
|
||||
"headers",
|
||||
"content",
|
||||
"truncated",
|
||||
"declared_bytes",
|
||||
"encoding",
|
||||
"url",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code,
|
||||
headers,
|
||||
content,
|
||||
truncated,
|
||||
declared_bytes,
|
||||
encoding,
|
||||
url,
|
||||
):
|
||||
self.status_code = status_code
|
||||
self.headers = headers
|
||||
self.content = content
|
||||
self.truncated = truncated
|
||||
self.declared_bytes = declared_bytes
|
||||
self.encoding = encoding
|
||||
self.url = url
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.content.decode(self.encoding or "utf-8", errors="replace")
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
request = httpx.Request("GET", self.url)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"HTTP {self.status_code} for {self.url}",
|
||||
request=request,
|
||||
response=httpx.Response(self.status_code, request=request),
|
||||
)
|
||||
|
||||
|
||||
def _get_public_url(
|
||||
url: str,
|
||||
headers: dict,
|
||||
timeout: int,
|
||||
max_redirects: int = 5,
|
||||
max_bytes: int | None = None,
|
||||
*,
|
||||
resolve_public_ips: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
|
||||
transport_factory: Callable[[ipaddress._BaseAddress], httpx.BaseTransport] | None = None,
|
||||
) -> _CappedFetch:
|
||||
"""Capped streaming GET with SSRF-guarded, DNS-pinned redirects."""
|
||||
resolve_public_ips = resolve_public_ips or _resolve_public_ips
|
||||
transport_factory = transport_factory or _PinnedTransport
|
||||
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
|
||||
current = url
|
||||
for _ in range(max_redirects + 1):
|
||||
ips = resolve_public_ips(current)
|
||||
req_headers = dict(headers or {})
|
||||
req_headers["Accept-Encoding"] = "identity"
|
||||
|
||||
with httpx.Client(
|
||||
headers=req_headers,
|
||||
timeout=timeout,
|
||||
follow_redirects=False,
|
||||
transport=transport_factory(ips[0]),
|
||||
) as client:
|
||||
with client.stream("GET", current) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
return _CappedFetch(
|
||||
response.status_code,
|
||||
response.headers,
|
||||
b"",
|
||||
False,
|
||||
None,
|
||||
response.encoding,
|
||||
str(response.url),
|
||||
)
|
||||
current = urljoin(str(response.url), location)
|
||||
continue
|
||||
|
||||
enc = (response.headers.get("content-encoding") or "").strip().lower()
|
||||
if enc and enc != "identity":
|
||||
raise httpx.RequestError(
|
||||
f"Refusing compressed response (Content-Encoding: {enc}) after "
|
||||
"requesting identity: cannot bound decoded body size",
|
||||
request=httpx.Request("GET", current),
|
||||
)
|
||||
|
||||
declared = None
|
||||
raw_len = response.headers.get("content-length")
|
||||
if raw_len and raw_len.isdigit():
|
||||
declared = int(raw_len)
|
||||
|
||||
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
|
||||
raise BodyTooLargeError(current, declared)
|
||||
|
||||
chunks = []
|
||||
read = 0
|
||||
truncated = False
|
||||
for chunk in response.iter_bytes():
|
||||
read += len(chunk)
|
||||
if read > cap:
|
||||
keep = cap - (read - len(chunk))
|
||||
if keep > 0:
|
||||
chunks.append(chunk[:keep])
|
||||
truncated = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
|
||||
return _CappedFetch(
|
||||
response.status_code,
|
||||
response.headers,
|
||||
b"".join(chunks),
|
||||
truncated,
|
||||
declared,
|
||||
response.encoding,
|
||||
str(response.url),
|
||||
)
|
||||
|
||||
raise httpx.RequestError(
|
||||
"Too many redirects", request=httpx.Request("GET", current)
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared wire values and scope markers for tool approval continuations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# Keep the existing wire values so the current route and no-build frontend do
|
||||
# not need a second protocol migration. ``approve`` no longer means one action;
|
||||
# it now selects chat-session scope.
|
||||
TASK_APPROVAL_DECISION = "approve_task"
|
||||
CHAT_SESSION_APPROVAL_DECISION = "approve"
|
||||
DENY_APPROVAL_DECISION = "deny"
|
||||
|
||||
# Session.get_context_messages() adds this server-owned marker only when the
|
||||
# session history contains a matching, resolved chat-session approval.
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
|
||||
|
||||
|
||||
class ToolApprovalScope(str, Enum):
|
||||
# Surfaces without a resumable chat (the skill tester, unattended audits)
|
||||
# keep the original one-use meaning: the sealed action runs and the gate
|
||||
# re-arms immediately for anything after it.
|
||||
SINGLE_ACTION = "single_action"
|
||||
TASK = "task"
|
||||
CHAT_SESSION = "chat_session"
|
||||
|
||||
|
||||
def scope_for_decision(decision: object) -> ToolApprovalScope | None:
|
||||
normalized = str(decision or "").strip().lower()
|
||||
if normalized == TASK_APPROVAL_DECISION:
|
||||
return ToolApprovalScope.TASK
|
||||
if normalized == CHAT_SESSION_APPROVAL_DECISION:
|
||||
return ToolApprovalScope.CHAT_SESSION
|
||||
return None
|
||||
+136
-15
@@ -1,8 +1,9 @@
|
||||
"""Opaque, exact, one-use approvals for tainted model-requested actions.
|
||||
"""Opaque exact-action approvals with explicit task and chat scopes.
|
||||
|
||||
The model may propose an action after untrusted context, but only the server
|
||||
stores and later executes the exact approved tool input. Browser-visible
|
||||
fields are display copies, never authority.
|
||||
The server still seals and claims the first displayed action exactly once. The
|
||||
selected scope then bypasses only the automatic post-external-context approval
|
||||
gate for the rest of the resumed task or chat session. Browser-visible fields
|
||||
are display copies, never authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,6 +17,13 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
DENY_APPROVAL_DECISION,
|
||||
TASK_APPROVAL_DECISION,
|
||||
ToolApprovalScope,
|
||||
scope_for_decision,
|
||||
)
|
||||
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
||||
|
||||
|
||||
@@ -33,6 +41,51 @@ def _normalized_workspace(workspace: Any) -> str:
|
||||
return os.path.realpath(os.path.expanduser(workspace))
|
||||
|
||||
|
||||
_MAX_APPROVAL_SELECTED_TOOLS = 512
|
||||
_MAX_APPROVAL_TOOL_NAME_CHARS = 512
|
||||
_MAX_APPROVAL_CONTINUATION_QUERY_CHARS = 4000
|
||||
|
||||
|
||||
def _normalized_selected_tools(
|
||||
selected_tools: Any,
|
||||
*,
|
||||
required_tool: Any = None,
|
||||
) -> tuple[str, ...]:
|
||||
if isinstance(selected_tools, str):
|
||||
selected_tools = (selected_tools,)
|
||||
try:
|
||||
values = selected_tools or ()
|
||||
names = {
|
||||
name.strip()
|
||||
for name in values
|
||||
if (
|
||||
isinstance(name, str)
|
||||
and name.strip()
|
||||
and len(name.strip()) <= _MAX_APPROVAL_TOOL_NAME_CHARS
|
||||
)
|
||||
}
|
||||
required_name = str(required_tool or "").strip()
|
||||
if required_name and len(required_name) <= _MAX_APPROVAL_TOOL_NAME_CHARS:
|
||||
names.add(required_name)
|
||||
ordered = sorted(names)
|
||||
if len(ordered) <= _MAX_APPROVAL_SELECTED_TOOLS:
|
||||
return tuple(ordered)
|
||||
kept = ordered[:_MAX_APPROVAL_SELECTED_TOOLS]
|
||||
if required_name and required_name in names and required_name not in kept:
|
||||
kept[-1] = required_name
|
||||
kept.sort()
|
||||
return tuple(kept)
|
||||
except TypeError:
|
||||
return ()
|
||||
|
||||
|
||||
def _normalized_continuation_query(value: Any) -> str:
|
||||
# The query is server-derived from the interrupted run and already lives in
|
||||
# session history. Keep the pending copy bounded because approvals are held
|
||||
# in memory until consumed or expired.
|
||||
return str(value or "").strip()[:_MAX_APPROVAL_CONTINUATION_QUERY_CHARS]
|
||||
|
||||
|
||||
def _canonical_digest(payload: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
@@ -60,6 +113,8 @@ def _binding_payload(
|
||||
document_version: Any,
|
||||
document_digest: Any,
|
||||
external_untrusted_context_seen: bool,
|
||||
selected_tools: Any,
|
||||
continuation_query: Any,
|
||||
effects: tuple[str, ...],
|
||||
result_integrity: str,
|
||||
) -> dict[str, Any]:
|
||||
@@ -76,6 +131,10 @@ def _binding_payload(
|
||||
),
|
||||
"document_digest": str(document_digest or "").strip().lower(),
|
||||
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
|
||||
"selected_tools": list(
|
||||
_normalized_selected_tools(selected_tools, required_tool=tool_name)
|
||||
),
|
||||
"continuation_query": _normalized_continuation_query(continuation_query),
|
||||
"effects": list(effects),
|
||||
"result_integrity": str(result_integrity),
|
||||
}
|
||||
@@ -99,26 +158,47 @@ class PendingToolApproval:
|
||||
digest: str
|
||||
created_at: float
|
||||
expires_at: float
|
||||
# Server-only continuation state. Both fields are digest-bound and never
|
||||
# exposed in the browser payload.
|
||||
selected_tools: tuple[str, ...] = ()
|
||||
continuation_query: str = ""
|
||||
|
||||
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": "tool_approval",
|
||||
"approval_id": self.approval_id,
|
||||
"question": "Allow this exact action once?",
|
||||
# The browser already owns this chat id. Persisting it with the
|
||||
# resolved card lets history-derived session grants remain bound to
|
||||
# this exact chat and prevents inheritance by a forked session.
|
||||
"session_id": self.session_id,
|
||||
"question": "Allow this task to continue?",
|
||||
"description": reason or (
|
||||
"Untrusted context influenced this run, so this action needs "
|
||||
"your explicit approval."
|
||||
"Untrusted context influenced this run, so continuing with "
|
||||
"otherwise-gated actions needs your explicit approval."
|
||||
),
|
||||
"options": [
|
||||
{
|
||||
"label": "Allow once",
|
||||
"value": "approve",
|
||||
"description": "Execute only the sealed action shown here.",
|
||||
"label": "Allow for this task",
|
||||
"value": TASK_APPROVAL_DECISION,
|
||||
"description": (
|
||||
"Execute the sealed action and allow every otherwise-gated "
|
||||
"action needed to finish this request. Current tool, account, "
|
||||
"workspace, and sandbox restrictions still apply."
|
||||
),
|
||||
},
|
||||
{
|
||||
"label": "Allow for this chat session",
|
||||
"value": CHAT_SESSION_APPROVAL_DECISION,
|
||||
"description": (
|
||||
"Execute the sealed action and stop asking at this gate for "
|
||||
"later requests in this chat. Current tool, account, workspace, "
|
||||
"and sandbox restrictions still apply."
|
||||
),
|
||||
},
|
||||
{
|
||||
"label": "Deny",
|
||||
"value": "deny",
|
||||
"description": "Do not execute it.",
|
||||
"value": DENY_APPROVAL_DECISION,
|
||||
"description": "Do not execute the proposed action.",
|
||||
},
|
||||
],
|
||||
"action": {
|
||||
@@ -137,12 +217,23 @@ class PendingToolApproval:
|
||||
|
||||
@dataclass
|
||||
class ExactToolApproval:
|
||||
"""A consumed grant that the dispatcher can claim exactly once."""
|
||||
"""A consumed exact first action plus an explicit continuation scope."""
|
||||
|
||||
pending: PendingToolApproval
|
||||
scope: ToolApprovalScope = ToolApprovalScope.TASK
|
||||
# The seam consumed by agent_loop. Both chat-card allow choices cover the
|
||||
# complete resumed task, because one-action scope there immediately
|
||||
# re-entered the same gate on the next round. Callers with no resumable
|
||||
# chat still get SINGLE_ACTION, which leaves the gate armed behind the
|
||||
# sealed action.
|
||||
allow_remaining_actions: bool = True
|
||||
_claimed: bool = field(default=False, init=False, repr=False)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def grants_chat_session(self) -> bool:
|
||||
return self.scope is ToolApprovalScope.CHAT_SESSION
|
||||
|
||||
def _matches_unlocked(
|
||||
self,
|
||||
*,
|
||||
@@ -175,6 +266,8 @@ class ExactToolApproval:
|
||||
external_untrusted_context_seen=(
|
||||
self.pending.external_untrusted_context_seen
|
||||
),
|
||||
selected_tools=self.pending.selected_tools,
|
||||
continuation_query=self.pending.continuation_query,
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
)
|
||||
@@ -255,6 +348,8 @@ class ToolApprovalStore:
|
||||
document_id: Any = None,
|
||||
document_version: Any = None,
|
||||
document_digest: Any = None,
|
||||
selected_tools: Any = None,
|
||||
continuation_query: Any = None,
|
||||
external_untrusted_context_seen: bool,
|
||||
capabilities: ToolCapabilities,
|
||||
) -> PendingToolApproval:
|
||||
@@ -272,6 +367,8 @@ class ToolApprovalStore:
|
||||
document_version=document_version,
|
||||
document_digest=document_digest,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
selected_tools=selected_tools,
|
||||
continuation_query=continuation_query,
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
)
|
||||
@@ -294,6 +391,8 @@ class ToolApprovalStore:
|
||||
digest=_canonical_digest(payload),
|
||||
created_at=now,
|
||||
expires_at=now + self._ttl_seconds,
|
||||
selected_tools=tuple(payload["selected_tools"]),
|
||||
continuation_query=payload["continuation_query"],
|
||||
)
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
@@ -331,7 +430,17 @@ class ToolApprovalStore:
|
||||
decision: Any,
|
||||
owner: Any,
|
||||
session_id: Any,
|
||||
allow_continuation: bool = True,
|
||||
) -> ExactToolApproval | None:
|
||||
"""Consume a pending approval.
|
||||
|
||||
``allow_continuation`` is the caller's assertion that it owns a
|
||||
resumable conversation the granted scope can apply to. Callers without
|
||||
one (the skill tester, unattended audits) pass ``False`` and get the
|
||||
original one-use grant, so a button labelled "Allow once" cannot widen
|
||||
into a run-long bypass just because the chat card reuses the same wire
|
||||
value.
|
||||
"""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
@@ -348,9 +457,21 @@ class ToolApprovalStore:
|
||||
# another owner's pending action.
|
||||
return None
|
||||
self._pending.pop(approval_key, None)
|
||||
if str(decision or "").strip().lower() != "approve":
|
||||
normalized_decision = str(decision or "").strip().lower()
|
||||
scope = scope_for_decision(normalized_decision)
|
||||
if scope is None:
|
||||
return None
|
||||
return ExactToolApproval(pending)
|
||||
if not allow_continuation:
|
||||
return ExactToolApproval(
|
||||
pending,
|
||||
scope=ToolApprovalScope.SINGLE_ACTION,
|
||||
allow_remaining_actions=False,
|
||||
)
|
||||
return ExactToolApproval(
|
||||
pending,
|
||||
scope=scope,
|
||||
allow_remaining_actions=True,
|
||||
)
|
||||
|
||||
def peek(self, approval_id: Any) -> PendingToolApproval | None:
|
||||
now = time.time()
|
||||
|
||||
@@ -14,6 +14,7 @@ from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||
|
||||
|
||||
@@ -618,13 +619,30 @@ class ToolRunSecurityContext:
|
||||
external_untrusted_context_seen: bool = False
|
||||
external_sources: list[str] = field(default_factory=list)
|
||||
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||
# Task-scope approval sets this for the resumed in-memory run. Chat-scope
|
||||
# approval is projected from the server-owned session history marker below.
|
||||
# The bypass affects only this automatic gate; current tool policy, ownership,
|
||||
# workspace confinement, and execution/sandbox restrictions still apply.
|
||||
approval_gate_bypassed: bool = False
|
||||
|
||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||
"""Promote any server-labelled untrusted prompt context into the gate."""
|
||||
if messages_contain_external_untrusted_context(messages):
|
||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||
message_list = list(messages or ())
|
||||
if any(
|
||||
isinstance(message, dict)
|
||||
and isinstance(message.get("metadata"), dict)
|
||||
and message["metadata"].get(
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
) is True
|
||||
for message in message_list
|
||||
):
|
||||
self.approval_gate_bypassed = True
|
||||
if messages_contain_external_untrusted_context(message_list):
|
||||
self.external_untrusted_context_seen = True
|
||||
|
||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||
if self.approval_gate_bypassed:
|
||||
return ToolGateDecision(True)
|
||||
if not self.external_untrusted_context_seen:
|
||||
return ToolGateDecision(True)
|
||||
capabilities = capabilities_for_action(tool_name, content)
|
||||
|
||||
+31
-20
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@@ -65,19 +65,31 @@ def format_utc_offset(offset_min: Optional[int]) -> str:
|
||||
return f"{sign}{hours:02d}:{minutes:02d}"
|
||||
|
||||
|
||||
def user_timezone() -> timezone:
|
||||
"""Return the best known user timezone as a fixed-offset tzinfo."""
|
||||
def _zoneinfo_from_name():
|
||||
"""Return ZoneInfo for the request's IANA name, or None if missing/invalid."""
|
||||
name = get_user_tz_name()
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def user_timezone() -> tzinfo:
|
||||
"""Return the best known user timezone.
|
||||
|
||||
A valid IANA name wins over x-tz-offset. The offset is a fixed number and
|
||||
can disagree with the name (wrong sign, stale client); the name carries DST.
|
||||
"""
|
||||
zone = _zoneinfo_from_name()
|
||||
if zone is not None:
|
||||
return zone
|
||||
offset = get_user_tz_offset()
|
||||
if offset is None:
|
||||
name = get_user_tz_name()
|
||||
if name:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now().astimezone().tzinfo or timezone.utc
|
||||
return timezone(timedelta(minutes=offset))
|
||||
if offset is not None:
|
||||
return timezone(timedelta(minutes=offset))
|
||||
return datetime.now().astimezone().tzinfo or timezone.utc
|
||||
|
||||
|
||||
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
|
||||
@@ -100,14 +112,13 @@ def _clock_label(dt: datetime) -> str:
|
||||
|
||||
def timezone_label(dt: Optional[datetime] = None) -> str:
|
||||
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
|
||||
offset = get_user_tz_offset()
|
||||
if offset is None:
|
||||
if dt is None:
|
||||
dt = datetime.now().astimezone()
|
||||
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
|
||||
if dt is None:
|
||||
dt = now_user_local()
|
||||
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
|
||||
offset_label = f"UTC{format_utc_offset(offset)}"
|
||||
name = get_user_tz_name()
|
||||
return f"{name}, {offset_label}" if name else offset_label
|
||||
if _zoneinfo_from_name() is not None:
|
||||
return f"{get_user_tz_name()}, {offset_label}"
|
||||
return offset_label
|
||||
|
||||
|
||||
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
|
||||
|
||||
@@ -34,6 +34,10 @@ 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"
|
||||
|
||||
+10
-11
@@ -10,8 +10,8 @@ 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 compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||
import chatModule from './js/chat.js?v=20260819approvalcontrol1';
|
||||
import compareModule from './js/compare/index.js?v=20260819approvalcontrol1';
|
||||
import documentModule from './js/document.js?v=20260815approvalsave1';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
@@ -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=20260819approvalcontrol1';
|
||||
import sessionModule from './js/sessions.js';
|
||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
@@ -50,6 +50,7 @@ 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';
|
||||
|
||||
@@ -1518,13 +1519,11 @@ function initializeEventListeners() {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// 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 => {
|
||||
// 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 => {
|
||||
// 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;
|
||||
@@ -3705,7 +3704,7 @@ function startOdysseusApp() {
|
||||
modelsModule.init(API_BASE);
|
||||
ragModule.init(API_BASE);
|
||||
presetsModule.init(API_BASE);
|
||||
searchModule.init(API_BASE);
|
||||
searchModule.init();
|
||||
chatModule.init(API_BASE);
|
||||
chatModule.initListeners();
|
||||
groupModule.init(API_BASE);
|
||||
|
||||
+8
-20
@@ -231,23 +231,11 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- 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>
|
||||
<!-- 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. -->
|
||||
<!-- 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
|
||||
@@ -2584,10 +2572,10 @@
|
||||
<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/gallery.js?v=20260708match1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260815toolapproval4"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260819approvalcontrol1"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/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=20260819approvalcontrol1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260819approvalcontrol1"></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>
|
||||
|
||||
+65
-17
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -345,8 +346,7 @@ function initSignupToggle() {
|
||||
|
||||
function initShareDefaultsToggle() {
|
||||
const toggle = el('adm-shareDefaultsToggle');
|
||||
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
getSettings()
|
||||
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
|
||||
.catch(e => console.warn('Settings fetch failed:', e));
|
||||
toggle.addEventListener('change', async () => {
|
||||
@@ -361,6 +361,9 @@ 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1893,8 +1896,16 @@ async function loadBuiltinTools() {
|
||||
const list = el('adm-builtin-tools-list');
|
||||
if (!list) return;
|
||||
try {
|
||||
const res = await fetch('/api/tools', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
// 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 tools = data.tools || [];
|
||||
if (!tools.length) { list.innerHTML = '<div class="admin-empty">No tools found</div>'; return; }
|
||||
|
||||
@@ -1968,17 +1979,50 @@ async function loadBuiltinTools() {
|
||||
});
|
||||
});
|
||||
|
||||
// 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',
|
||||
});
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
function _updateCatCounter(catEl) {
|
||||
if (!catEl) return;
|
||||
@@ -1993,7 +2037,9 @@ async function loadBuiltinTools() {
|
||||
// Wire individual tool toggles
|
||||
list.querySelectorAll('input[data-tool-id]').forEach(chk => {
|
||||
chk.addEventListener('change', async () => {
|
||||
await _saveToolState();
|
||||
await _saveToolState([
|
||||
{ id: chk.dataset.toolId, enabled: chk.checked },
|
||||
]);
|
||||
_updateCatCounter(chk.closest('.admin-tool-category'));
|
||||
});
|
||||
});
|
||||
@@ -2004,8 +2050,10 @@ 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();
|
||||
await _saveToolState(changes);
|
||||
_updateCatCounter(catEl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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;
|
||||
}
|
||||
+21
-17
@@ -8,8 +8,8 @@
|
||||
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=20260819approvalcontrol1';
|
||||
import chatStream from './chatStream.js?v=20260819approvalcontrol1';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
@@ -35,6 +35,7 @@ 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;
|
||||
@@ -61,20 +62,18 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
|
||||
let _contextHeaderBound = false;
|
||||
let _pendingToolApproval = null;
|
||||
|
||||
function _submitToolApprovalWhenIdle(approvalId, label) {
|
||||
function _submitToolApprovalWhenIdle(approvalId) {
|
||||
if (
|
||||
!_pendingToolApproval
|
||||
|| _pendingToolApproval.approval_id !== approvalId
|
||||
) return;
|
||||
if (isStreaming || _sendInFlight) {
|
||||
setTimeout(() => _submitToolApprovalWhenIdle(approvalId, label), 120);
|
||||
setTimeout(() => _submitToolApprovalWhenIdle(approvalId), 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();
|
||||
@@ -83,16 +82,13 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
|
||||
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;
|
||||
if (!detail.approval_id || !['approve', 'approve_task', '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'),
|
||||
);
|
||||
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
|
||||
});
|
||||
|
||||
function _fmtContextNumber(n) {
|
||||
@@ -1308,10 +1304,10 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
|
||||
}
|
||||
|
||||
const el = uiModule.el;
|
||||
const msg = el('message').value;
|
||||
const msg = approvalForSend ? '' : el('message').value;
|
||||
// Allow empty text when a regen carries over the original message's
|
||||
// attachment ids — a photo-only message still has something to send.
|
||||
if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
||||
if (!msg.trim() && !approvalForSend && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
|
||||
|
||||
// --- Slash commands: execute directly without AI (no session needed) ---
|
||||
if (!approvalForSend && isCommand(msg.trim())) {
|
||||
@@ -1589,7 +1585,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
|
||||
|
||||
const userDisplay = _displayOverride || msg;
|
||||
_displayOverride = null;
|
||||
const skipBubble = _hideUserBubble;
|
||||
const skipBubble = _hideUserBubble || !!approvalForSend;
|
||||
_hideUserBubble = false;
|
||||
// Auto-recovery counter: carries across a turn's auto-continues, but resets
|
||||
// when the user genuinely sends a new message (so each task gets a fresh cap).
|
||||
@@ -1832,7 +1828,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
|
||||
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', _finalMsgWithInject);
|
||||
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
|
||||
fd.append('session', streamSessionId);
|
||||
if (approvalForSend) {
|
||||
fd.append('tool_approval_id', approvalForSend.approval_id);
|
||||
@@ -2872,7 +2868,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
break;
|
||||
}
|
||||
if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
if (json.delta || json.type === 'agent_prep' || json.type === 'tool_approval_resolved' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
clearResponseTimeout();
|
||||
clearProcessingProbe();
|
||||
clearFirstTokenWaitTimers();
|
||||
@@ -2889,6 +2885,14 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (json.type === 'tool_approval_resolved') {
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
if (!_isBg && roundHolder && roundHolder !== holder) roundHolder.remove();
|
||||
if (!_isBg && holder) holder.remove();
|
||||
continue;
|
||||
}
|
||||
if (json.delta) {
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
@@ -6606,7 +6610,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
|
||||
// Images → Gallery editor.
|
||||
if (isImage) {
|
||||
try {
|
||||
const gx = await import('./galleryEditor.js');
|
||||
const gx = await loadPanel('editor');
|
||||
if (gx.openEditor) { gx.openEditor(url, id, null, name); return; }
|
||||
} catch (e) { console.warn('gallery open failed', e); }
|
||||
window.open(url, '_blank');
|
||||
|
||||
+84
-18
@@ -9,7 +9,9 @@ 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>';
|
||||
@@ -445,8 +447,12 @@ function stripExecutedFence(match, tag, inline, body) {
|
||||
|
||||
async function loadExecFenceRegex() {
|
||||
try {
|
||||
const res = await fetch('/api/tools', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
// 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 tags = (data.tools || [])
|
||||
.map((t) => t.id)
|
||||
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
|
||||
@@ -1548,7 +1554,7 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
||||
try {
|
||||
const [galleryMod, editorMod] = await Promise.all([
|
||||
import('./gallery.js'),
|
||||
import('./galleryEditor.js'),
|
||||
loadPanel('editor'),
|
||||
]);
|
||||
// Ensure the Gallery modal is open so the editor has a container
|
||||
// to render into; switch its tabs to the Edit tab.
|
||||
@@ -2321,6 +2327,42 @@ export function removeAskUserCards(root) {
|
||||
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
// While a choice card is visible, let plain 1–3 activate the corresponding
|
||||
// rendered option. Reuse the option's click path so the question keeps its
|
||||
// existing submission semantics. Tool approval cards are excluded: that card
|
||||
// exists to make consent deliberate after untrusted context influenced the
|
||||
// run, and its first option is the widest grant, so a stray digit must not
|
||||
// answer it.
|
||||
function _handleAskUserShortcut(event) {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.repeat
|
||||
|| event.isComposing
|
||||
|| event.ctrlKey
|
||||
|| event.altKey
|
||||
|| event.metaKey
|
||||
|| event.shiftKey
|
||||
) return;
|
||||
if (!/^[1-3]$/.test(event.key)) return;
|
||||
|
||||
const target = event.target;
|
||||
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
|
||||
const focusedCard = document.activeElement?.closest?.('.ask-user-card') || null;
|
||||
const mainCard = document.querySelector('#chat-history .ask-user-card');
|
||||
const compareCards = document.querySelectorAll('.compare-pane .ask-user-card');
|
||||
const card = focusedCard || mainCard || (compareCards.length === 1 ? compareCards[0] : null);
|
||||
if (!card) return;
|
||||
if (card.dataset.askUserKind === 'tool_approval') return;
|
||||
const option = card.querySelectorAll('.ask-user-option')[Number(event.key) - 1];
|
||||
if (!option || option.disabled) return;
|
||||
|
||||
event.preventDefault();
|
||||
option.click();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', _handleAskUserShortcut);
|
||||
|
||||
/**
|
||||
* Render an ask_user payload as a durable choice card.
|
||||
*
|
||||
@@ -2330,11 +2372,15 @@ export function removeAskUserCards(root) {
|
||||
*/
|
||||
export function renderAskUserCard(payload, options) {
|
||||
const aq = payload || {};
|
||||
if (aq.resolved) return null;
|
||||
const opts = Array.isArray(aq.options) ? aq.options : [];
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
const renderOptions = options || {};
|
||||
const chatBox = renderOptions.root || document.getElementById('chat-history');
|
||||
const onSubmit = typeof renderOptions.onSubmit === 'function'
|
||||
? renderOptions.onSubmit
|
||||
: null;
|
||||
if (!chatBox || !aq.question || opts.length < 2) return null;
|
||||
|
||||
const renderOptions = options || {};
|
||||
removeAskUserCards(chatBox);
|
||||
|
||||
const card = document.createElement('div');
|
||||
@@ -2343,6 +2389,7 @@ export function renderAskUserCard(payload, options) {
|
||||
card.tabIndex = -1;
|
||||
const multi = !!aq.multi;
|
||||
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
|
||||
card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';
|
||||
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||
|
||||
const head = document.createElement('div');
|
||||
@@ -2351,7 +2398,6 @@ export function renderAskUserCard(payload, options) {
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'modal-close ask-user-close';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
@@ -2394,6 +2440,17 @@ export function renderAskUserCard(payload, options) {
|
||||
|
||||
const send = (text) => {
|
||||
if (!text) return;
|
||||
if (onSubmit) {
|
||||
const accepted = onSubmit({
|
||||
kind: 'answer',
|
||||
text,
|
||||
label: text,
|
||||
payload: aq,
|
||||
card,
|
||||
});
|
||||
if (accepted !== false) card.remove();
|
||||
return;
|
||||
}
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
if (input) input.value = text;
|
||||
@@ -2427,17 +2484,26 @@ export function renderAskUserCard(payload, options) {
|
||||
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)
|
||||
: '',
|
||||
},
|
||||
}));
|
||||
const 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)
|
||||
: '',
|
||||
};
|
||||
if (onSubmit) {
|
||||
const accepted = onSubmit({
|
||||
kind: 'tool_approval',
|
||||
...detail,
|
||||
payload: aq,
|
||||
card,
|
||||
});
|
||||
if (accepted !== false) card.remove();
|
||||
} else {
|
||||
card.remove();
|
||||
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }));
|
||||
}
|
||||
} else {
|
||||
send(label);
|
||||
}
|
||||
@@ -2622,7 +2688,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
box.appendChild(threadWrap);
|
||||
}
|
||||
for (const ev of roundTools) {
|
||||
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||
let outHtml = '';
|
||||
if (ev.output && ev.output.trim()) {
|
||||
|
||||
@@ -9,6 +9,35 @@ import markdownModule from './markdown.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import documentModule from './document.js?v=20260815approvalsave1';
|
||||
|
||||
// Tool approvals are control-plane submits for the current chat. chat.js
|
||||
// deliberately leaves the composer untouched, then programmatically clicks the
|
||||
// shared send button after it records the sealed approval id/decision. That
|
||||
// button is polymorphic: with an empty composer it can mean New chat or Record
|
||||
// voice instead of Send. Intercept only the programmatic approval click and
|
||||
// route it through the form submit path, which already reaches chat.js directly.
|
||||
document.addEventListener('odysseus:tool-approval', () => {
|
||||
const sendButton = document.querySelector('.send-btn');
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
if (!sendButton || !chatForm) return;
|
||||
|
||||
const interceptApprovalClick = (event) => {
|
||||
// A real user click must retain the normal send/new-chat/STT behavior.
|
||||
if (event.isTrusted) return;
|
||||
sendButton.removeEventListener('click', interceptApprovalClick, true);
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (chatForm.requestSubmit) chatForm.requestSubmit();
|
||||
else chatForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
};
|
||||
|
||||
sendButton.addEventListener('click', interceptApprovalClick, true);
|
||||
// Fail-safe cleanup if the approval continuation never reaches its deferred
|
||||
// synthetic click (for example because the surrounding view is torn down).
|
||||
setTimeout(() => {
|
||||
sendButton.removeEventListener('click', interceptApprovalClick, true);
|
||||
}, 60000);
|
||||
}, true);
|
||||
|
||||
/**
|
||||
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
||||
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
|
||||
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
||||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
|
||||
import {
|
||||
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
||||
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
||||
@@ -1006,11 +1006,16 @@ async function _executeCompare(message) {
|
||||
console.error('Compare error:', err);
|
||||
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
|
||||
} finally {
|
||||
state._streaming = false;
|
||||
_setSendBtn('send');
|
||||
// Re-enable header buttons
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
|
||||
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
|
||||
// A pane may have started its own ask_user/approval continuation while the
|
||||
// original all-pane Promise was settling. Keep Compare busy until every
|
||||
// pane-owned controller is gone instead of exposing a second broadcast send.
|
||||
const compareStillStreaming = state._abortControllers.some(Boolean);
|
||||
state._streaming = compareStillStreaming;
|
||||
_setSendBtn(compareStillStreaming ? 'stop' : 'send');
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
|
||||
button.disabled = compareStillStreaming;
|
||||
button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
|
||||
button.style.pointerEvents = compareStillStreaming ? 'none' : '';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1514,7 +1519,7 @@ async function showShufflePoolEditor() {
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
registerCompareActions({ stopAll, resetCompare });
|
||||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
|
||||
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
|
||||
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+189
-6
@@ -1,7 +1,7 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { addFinishBadge } from './vote.js';
|
||||
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260819approvalcontrol1';
|
||||
import markdownModule from '../markdown.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import uiModule from '../ui.js';
|
||||
@@ -24,11 +24,157 @@ function _safeHttpHref(raw) {
|
||||
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||
let _rerollPane = null;
|
||||
let _autoPreviewHtml = null;
|
||||
let _setSendBtn = null;
|
||||
|
||||
/** Register external functions that live in compare.js. */
|
||||
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
|
||||
function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
|
||||
_rerollPane = rerollPane;
|
||||
_autoPreviewHtml = autoPreviewHtml;
|
||||
_setSendBtn = setSendBtn;
|
||||
}
|
||||
|
||||
function _paneSessionIsCurrent(paneIdx, sessionId) {
|
||||
return Boolean(
|
||||
state.isActive
|
||||
&& state._paneSessionIds[paneIdx] === sessionId
|
||||
&& document.getElementById('cmp-history-' + paneIdx)
|
||||
);
|
||||
}
|
||||
|
||||
function _setCompareBusy(active) {
|
||||
state._streaming = Boolean(active);
|
||||
if (_setSendBtn) _setSendBtn(active ? 'stop' : 'send');
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
|
||||
button.disabled = Boolean(active);
|
||||
button.style.opacity = active ? '0.25' : '0.7';
|
||||
button.style.pointerEvents = active ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
function _syncCompareBusyFromPanes() {
|
||||
_setCompareBusy((state._abortControllers || []).some(Boolean));
|
||||
}
|
||||
|
||||
function _appendPaneMessage(hist, role, text) {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
|
||||
const roleEl = document.createElement('div');
|
||||
roleEl.className = 'role';
|
||||
roleEl.textContent = role === 'user' ? 'You' : 'AI';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
body.textContent = text || '';
|
||||
message.appendChild(roleEl);
|
||||
message.appendChild(body);
|
||||
hist.appendChild(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function _createPaneContinuationMessage(hist) {
|
||||
const message = _appendPaneMessage(hist, 'assistant', '');
|
||||
const body = message.querySelector('.body');
|
||||
if (spinnerModule) {
|
||||
const spinner = spinnerModule.create('Continuing...', 'right');
|
||||
body.appendChild(spinner.createElement());
|
||||
spinner.start();
|
||||
message._spinner = spinner;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function _restorePaneAskUserCard(paneIdx, sessionId, submission, originController) {
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
const restored = _renderPaneAskUserCard(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
submission.payload || {},
|
||||
hist,
|
||||
null,
|
||||
originController,
|
||||
);
|
||||
if (uiModule) {
|
||||
uiModule.showError(
|
||||
restored
|
||||
? 'This pane is still streaming — choose again once it settles.'
|
||||
: 'Compare pane is still streaming; the choice was not sent.',
|
||||
);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
function _resumePaneChoiceWhenIdle(paneIdx, sessionId, originController, submission) {
|
||||
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return false;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const resume = () => {
|
||||
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return;
|
||||
const activeController = state._abortControllers[paneIdx];
|
||||
if (activeController === originController) {
|
||||
if (Date.now() - startedAt < 10000) {
|
||||
setTimeout(resume, 25);
|
||||
return;
|
||||
}
|
||||
// The originating stream never released the pane. The card was already
|
||||
// removed when the choice was accepted, so put it back rather than
|
||||
// swallowing a decision the user made.
|
||||
_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);
|
||||
return;
|
||||
}
|
||||
// A reroll/model replacement already owns this pane. Never send the stale
|
||||
// choice into that replacement stream or session UI.
|
||||
if (activeController) return;
|
||||
|
||||
const hist = document.getElementById('cmp-history-' + paneIdx);
|
||||
if (!hist) return;
|
||||
hist.querySelectorAll('.ask-user-card').forEach((card) => card.remove());
|
||||
|
||||
const isApproval = submission.kind === 'tool_approval';
|
||||
const message = isApproval ? '' : String(submission.text || submission.label || '');
|
||||
if (!isApproval) _appendPaneMessage(hist, 'user', message);
|
||||
const aiMessage = _createPaneContinuationMessage(hist);
|
||||
hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
const resumeOptions = { skipBadge: true };
|
||||
if (isApproval) {
|
||||
resumeOptions.toolApproval = {
|
||||
approval_id: String(submission.approval_id || ''),
|
||||
decision: String(submission.decision || '').toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
_setCompareBusy(true);
|
||||
streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)
|
||||
.catch((error) => {
|
||||
console.error('Compare pane continuation failed:', error);
|
||||
if (uiModule) uiModule.showError('Compare continuation failed: ' + error.message);
|
||||
})
|
||||
.finally(_syncCompareBusyFromPanes);
|
||||
};
|
||||
|
||||
setTimeout(resume, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _renderPaneAskUserCard(paneIdx, sessionId, payload, hist, aiMsgEl, originController) {
|
||||
if (!hist || !hist.isConnected || !_paneSessionIsCurrent(paneIdx, sessionId)) return null;
|
||||
if (aiMsgEl && aiMsgEl._spinner) {
|
||||
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
const card = renderAskUserCard(payload, {
|
||||
root: hist,
|
||||
onSubmit: (submission) => _resumePaneChoiceWhenIdle(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
originController,
|
||||
submission,
|
||||
),
|
||||
});
|
||||
if (card) {
|
||||
card.dataset.comparePane = String(paneIdx);
|
||||
card.dataset.compareSession = String(sessionId);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
|
||||
@@ -164,6 +310,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
let metrics = null;
|
||||
let timedOut = false;
|
||||
let streamOk = false;
|
||||
let awaitingChoice = false;
|
||||
let currentToolBlock = null; // track active agent tool block
|
||||
// Idle timeout — abort only if no data is received for this many seconds.
|
||||
// Long generations (SVG, big code) are fine as long as the stream stays
|
||||
@@ -219,6 +366,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
const fd = new FormData();
|
||||
fd.append('message', message);
|
||||
fd.append('session', sessionId);
|
||||
if (opts.toolApproval) {
|
||||
fd.append('tool_approval_id', opts.toolApproval.approval_id || '');
|
||||
fd.append('tool_approval_decision', opts.toolApproval.decision || '');
|
||||
}
|
||||
|
||||
// Compare mode determines what tools/features are enabled
|
||||
const isAgent = state._compareMode === 'agent';
|
||||
@@ -322,6 +473,36 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pane-local question / approval selector ──
|
||||
} else if (json.type === 'ask_user') {
|
||||
awaitingChoice = true;
|
||||
_renderPaneAskUserCard(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
json.data || {},
|
||||
hist,
|
||||
aiMsgEl,
|
||||
ac,
|
||||
);
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
// Deny ends as a tiny resolution-only stream, so replace the
|
||||
// continuation spinner with an explicit pane-local result.
|
||||
} else if (json.type === 'tool_approval_resolved') {
|
||||
if (aiMsgEl._spinner) {
|
||||
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
accumulated = json.decision === 'deny' ? 'Denied.' : 'Approval recorded.';
|
||||
let target = aiMsgEl._textEl;
|
||||
if (!target) {
|
||||
target = document.createElement('div');
|
||||
target.className = 'compare-text-content';
|
||||
aiBody.appendChild(target);
|
||||
aiMsgEl._textEl = target;
|
||||
}
|
||||
target.textContent = accumulated;
|
||||
|
||||
// ── Tool start (bash, web search agent tool) ──
|
||||
} else if (json.type === 'tool_start') {
|
||||
// Finalize any accumulated text before the tool block
|
||||
@@ -640,19 +821,21 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
// TTFT removed from the header per user request — just show total time.
|
||||
_timerEl.textContent = _formatMs(_totalMs);
|
||||
}
|
||||
state._abortControllers[paneIdx] = null;
|
||||
if (state._abortControllers[paneIdx] === ac) {
|
||||
state._abortControllers[paneIdx] = null;
|
||||
}
|
||||
// Hide stop button, show response action buttons
|
||||
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (_paneElFinal) {
|
||||
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
||||
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
||||
if (accumulated.trim()) {
|
||||
if (!awaitingChoice && accumulated.trim()) {
|
||||
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
}
|
||||
}
|
||||
state._paneMetrics[paneIdx] = metrics;
|
||||
state._paneElapsed[paneIdx] = _totalMs;
|
||||
if (!opts.skipBadge) {
|
||||
if (!opts.skipBadge && !awaitingChoice) {
|
||||
if (streamOk) {
|
||||
state._finishOrder++;
|
||||
if (state._parallel) {
|
||||
@@ -682,7 +865,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
}
|
||||
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
|
||||
if (streamOk && state._expectedAnswer) {
|
||||
if (streamOk && !awaitingChoice && state._expectedAnswer) {
|
||||
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
|
||||
}
|
||||
// Show copy/reroll buttons now that response exists
|
||||
|
||||
@@ -15,6 +15,7 @@ let _getPlatform;
|
||||
let _serverByVal;
|
||||
let _isWindows;
|
||||
let _buildEnvPrefix;
|
||||
let _psQuote;
|
||||
let _buildServeCmd;
|
||||
let _detectBackend;
|
||||
let _detectToolParser;
|
||||
@@ -538,7 +539,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 = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
|
||||
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (env === 'conda' && envPath) {
|
||||
payload.env_prefix = 'conda activate ' + envPath;
|
||||
}
|
||||
@@ -652,6 +653,7 @@ 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,6 +338,7 @@ let _sshPrefix;
|
||||
let _getPlatform;
|
||||
let _isWindows;
|
||||
let _buildEnvPrefix;
|
||||
let _psQuote;
|
||||
let _loadPresets;
|
||||
let _savePresets;
|
||||
let _copyText;
|
||||
@@ -1971,7 +1972,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
let envPrefix = '';
|
||||
if (_isWindows()) {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
envPrefix = '& ' + (_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
||||
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
envPrefix = 'conda activate ' + _envState.envPath;
|
||||
}
|
||||
@@ -4402,6 +4403,7 @@ 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;
|
||||
|
||||
@@ -9738,6 +9738,11 @@ 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,
|
||||
|
||||
@@ -23,6 +23,7 @@ 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';
|
||||
@@ -993,8 +994,7 @@ function _syncEmailReminderBellVisibility(enabled) {
|
||||
|
||||
async function _loadEmailReminderBellVisibility() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await res.json();
|
||||
const settings = await getSettings();
|
||||
_syncEmailReminderBellVisibility(settings.reminder_channel === 'email');
|
||||
} catch (_) {
|
||||
_syncEmailReminderBellVisibility(false);
|
||||
|
||||
+49
-1
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1';
|
||||
import { loadPanel } from './panels.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
@@ -15,6 +15,54 @@ 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,6 +3,7 @@
|
||||
// ============================================
|
||||
|
||||
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',
|
||||
@@ -56,8 +57,7 @@ export function initKeyboardShortcuts(modules) {
|
||||
window._odysseusKeybinds = { ..._defaultKeybinds };
|
||||
|
||||
// Load saved keybinds
|
||||
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
getSettings()
|
||||
.then(s => { if (s.keybinds) window._odysseusKeybinds = { ..._defaultKeybinds, ...s.keybinds }; })
|
||||
.catch(() => {});
|
||||
|
||||
|
||||
+202
-63
@@ -10,6 +10,127 @@ 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('#')) {
|
||||
@@ -631,49 +752,45 @@ export function mdToHtml(src, opts) {
|
||||
|
||||
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
|
||||
const mathBlocks = [];
|
||||
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; }
|
||||
});
|
||||
}
|
||||
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();
|
||||
|
||||
// Handle pipe tables
|
||||
s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => {
|
||||
@@ -826,19 +943,47 @@ export function renderContent(content) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize any unprocessed Mermaid diagrams in a container (or whole document)
|
||||
* Initialize any unprocessed Mermaid diagrams in a container (or whole document).
|
||||
* Returns a promise so callers can await the (lazy) library load if they need to.
|
||||
*/
|
||||
export function renderMermaid(container) {
|
||||
if (!window.mermaid) return;
|
||||
initMermaid();
|
||||
const target = container || document;
|
||||
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);
|
||||
}
|
||||
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 markdownModule = {
|
||||
@@ -853,20 +998,14 @@ const markdownModule = {
|
||||
extractThinkingBlocks,
|
||||
normalizeThinkingMarkup,
|
||||
startsWithReasoningPrefix,
|
||||
renderMermaid
|
||||
renderMermaid,
|
||||
renderMath,
|
||||
ensureMermaid,
|
||||
ensureKatex
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
+9
-5
@@ -4,20 +4,21 @@
|
||||
* Search settings management — reads active provider from admin settings.
|
||||
*/
|
||||
|
||||
let API_BASE = '';
|
||||
import { getSettings, invalidateSettings } from './appConfig.js';
|
||||
|
||||
let _provider = 'searxng';
|
||||
let _loaded = false;
|
||||
|
||||
export function init(apiBase) {
|
||||
API_BASE = apiBase;
|
||||
// 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() {
|
||||
// Fetch provider on init so it's ready when chat needs it
|
||||
_fetchProvider();
|
||||
}
|
||||
|
||||
async function _fetchProvider() {
|
||||
try {
|
||||
const res = await fetch((API_BASE || '') + '/api/auth/settings', { credentials: 'same-origin' });
|
||||
const s = await res.json();
|
||||
const s = await getSettings();
|
||||
_provider = s.search_provider || 'searxng';
|
||||
_loaded = true;
|
||||
} catch (e) { /* keep default */ }
|
||||
@@ -39,6 +40,9 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
+46
-66
@@ -26,11 +26,37 @@ 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 esc(s) { return uiModule.esc(s); }
|
||||
function safeRasterDataUrl(raw) {
|
||||
@@ -276,10 +302,7 @@ function _bindFallbackWidget(opts) {
|
||||
var body = {};
|
||||
body[settingKey] = clean;
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
await _postSettings(body);
|
||||
} catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
|
||||
}
|
||||
|
||||
@@ -389,12 +412,9 @@ async function initDefaultChat() {
|
||||
|
||||
async function saveDefault() {
|
||||
try {
|
||||
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
|
||||
})
|
||||
await _postSettings({
|
||||
default_endpoint_id: epSel.value,
|
||||
default_model: modelSel.value
|
||||
});
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(function() { msg.textContent = ''; }, 2000);
|
||||
@@ -449,12 +469,9 @@ async function initUtilityModel() {
|
||||
// no toggle, "—" means "unset, use chat").
|
||||
async function saveUtility() {
|
||||
try {
|
||||
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 || ''
|
||||
})
|
||||
await _postSettings({
|
||||
utility_endpoint_id: epSel.value || '',
|
||||
utility_model: modelSel.value || ''
|
||||
});
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(function() { msg.textContent = ''; }, 1500);
|
||||
@@ -547,10 +564,7 @@ async function initTeacherModel() {
|
||||
spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value;
|
||||
}
|
||||
var enabled = enabledToggle ? !!enabledToggle.checked : false;
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ teacher_enabled: enabled, teacher_model: spec })
|
||||
});
|
||||
await _postSettings({ 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);
|
||||
@@ -625,8 +639,7 @@ async function initImageSettings() {
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
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 }) });
|
||||
const res = await _postSettings({ 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)'; }
|
||||
@@ -700,8 +713,7 @@ async function initVisionSettings() {
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
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 }) });
|
||||
await _postSettings({ 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)'; }
|
||||
}
|
||||
@@ -782,8 +794,7 @@ async function initTtsSettings() {
|
||||
|
||||
async function saveTTS() {
|
||||
try {
|
||||
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' }) });
|
||||
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' });
|
||||
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)'; }
|
||||
@@ -944,9 +955,7 @@ async function initSttSettings() {
|
||||
async function saveSTT() {
|
||||
try {
|
||||
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
|
||||
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() }) });
|
||||
await _postSettings({ 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();
|
||||
@@ -1102,10 +1111,7 @@ async function initSearchSettings() {
|
||||
payload[kf] = keyInput.value.trim();
|
||||
_settings[kf] = keyInput.value.trim();
|
||||
}
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
await _postSettings(payload);
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(refreshStatus, 2000);
|
||||
if (searchModule && searchModule.refresh) searchModule.refresh();
|
||||
@@ -1257,11 +1263,7 @@ async function initSearchSettings() {
|
||||
async function _saveFallbackChain(chain) {
|
||||
_settings.search_fallback_chain = chain;
|
||||
try {
|
||||
await fetch('/api/auth/settings', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ search_fallback_chain: chain }),
|
||||
});
|
||||
await _postSettings({ 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)'; }
|
||||
@@ -1425,10 +1427,7 @@ async function initResearchSettings() {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
await _postSettings(payload);
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(showStatus, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
@@ -1492,10 +1491,7 @@ async function initResearchSearchSettings() {
|
||||
|
||||
async function saveResearchSearch() {
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ research_search_provider: searchSel.value })
|
||||
});
|
||||
await _postSettings({ 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)'; }
|
||||
@@ -1537,10 +1533,7 @@ async function initAgentSettings() {
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
await _postSettings(payload);
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
@@ -1935,11 +1928,7 @@ async function initShortcuts() {
|
||||
|
||||
async function saveKeybinds() {
|
||||
try {
|
||||
await fetch('/api/auth/settings', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keybinds }),
|
||||
});
|
||||
await _postSettings({ keybinds });
|
||||
// Update global keybinds so they take effect immediately
|
||||
window._odysseusKeybinds = keybinds;
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
|
||||
@@ -2232,11 +2221,7 @@ async function initReminderSettings() {
|
||||
pubDebounce = setTimeout(async () => {
|
||||
try {
|
||||
const val = pubUrlIn.value.trim().replace(/\/+$/, '');
|
||||
await fetch('/api/auth/settings', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ app_public_url: val }),
|
||||
});
|
||||
await _postSettings({ app_public_url: val });
|
||||
if (pubUrlMsg) {
|
||||
pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)';
|
||||
pubUrlMsg.style.color = 'var(--green,#50fa7b)';
|
||||
@@ -2534,12 +2519,7 @@ async function initReminderSettings() {
|
||||
|
||||
async function save(patch) {
|
||||
try {
|
||||
await fetch('/api/auth/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
await _postSettings(patch);
|
||||
} catch (e) { console.warn('Failed to save reminder settings', e); }
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ 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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -5220,8 +5221,7 @@ async function _cmdShortcuts(args, ctx) {
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' });
|
||||
const settings = await res.json();
|
||||
const settings = await getSettings();
|
||||
if (settings.keybinds) {
|
||||
keybinds = { ...keybinds, ...settings.keybinds };
|
||||
}
|
||||
|
||||
+19
-18
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -214,31 +215,31 @@ async function _fetchActions() {
|
||||
return _builtinActions;
|
||||
}
|
||||
|
||||
let _urgentEmailSettings = null;
|
||||
async function _fetchUrgentEmailSettings() {
|
||||
if (_urgentEmailSettings) return _urgentEmailSettings;
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
_urgentEmailSettings = await res.json();
|
||||
return await getSettings();
|
||||
} catch (e) {
|
||||
_urgentEmailSettings = { urgent_email_prompt: '' };
|
||||
return { urgent_email_prompt: '' };
|
||||
}
|
||||
return _urgentEmailSettings;
|
||||
}
|
||||
|
||||
async function _saveUrgentEmailSettings(prompt) {
|
||||
_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 || '',
|
||||
}),
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
const _EMAIL_ACCOUNT_ACTIONS = new Set([
|
||||
|
||||
+6
-3
@@ -1,6 +1,8 @@
|
||||
// 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;
|
||||
@@ -30,10 +32,11 @@ class AITTSManager {
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
// Check user setting first — if TTS is disabled in settings, don't show buttons
|
||||
// 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.
|
||||
try {
|
||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await settingsRes.json();
|
||||
const settings = await getSettings();
|
||||
if (settings.tts_enabled === false) {
|
||||
this.available = false;
|
||||
this._provider = 'disabled';
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+3587
File diff suppressed because one or more lines are too long
+100
-5
@@ -7,16 +7,42 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v376-settings-title-icons';
|
||||
const CACHE_NAME = 'odysseus-v380-shared-config-image-editor-lazy-katex-mermaid';
|
||||
|
||||
// Core shell precached on install so repeat opens are instant without any
|
||||
// network wait. Keep this list in sync with the <script type="module"> tags
|
||||
// and <link rel="stylesheet"> in index.html.
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
const KATEX_FONTS = [
|
||||
'AMS-Regular', 'Caligraphic-Bold', 'Caligraphic-Regular',
|
||||
'Fraktur-Bold', 'Fraktur-Regular',
|
||||
'Main-Bold', 'Main-BoldItalic', 'Main-Italic', 'Main-Regular',
|
||||
'Math-BoldItalic', 'Math-Italic',
|
||||
'SansSerif-Bold', 'SansSerif-Italic', 'SansSerif-Regular',
|
||||
'Script-Regular',
|
||||
'Size1-Regular', 'Size2-Regular', 'Size3-Regular', 'Size4-Regular',
|
||||
'Typewriter-Regular',
|
||||
].map(name => `/static/lib/katex/fonts/KaTeX_${name}.woff2`);
|
||||
|
||||
|
||||
// Two lists, two jobs — they are no longer the same set and must not be
|
||||
// "resynced" back into one:
|
||||
//
|
||||
// PRECACHE = the app shell. Mirrors the <script type="module"> tags
|
||||
// and <link rel="stylesheet"> in index.html — i.e. what
|
||||
// loads before first paint.
|
||||
// PANEL_PRECACHE = modules that index.html deliberately does NOT load,
|
||||
// because js/panels.js imports them on first use. They are
|
||||
// off the critical path, not out of the offline manifest:
|
||||
// without them here, a panel the user never opened while
|
||||
// online could not open offline at all.
|
||||
//
|
||||
// Both are fetched at install time, in the background. Entries must match the
|
||||
// exact URL the browser requests, query string included.
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/style.css',
|
||||
'/static/app.js',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
'/static/js/ui.js',
|
||||
'/static/js/markdown.js',
|
||||
'/static/js/dragSort.js',
|
||||
@@ -61,6 +87,75 @@ const PRECACHE = [
|
||||
'/static/js/sidebar-layout.js',
|
||||
'/static/js/section-management.js',
|
||||
'/static/lib/highlight.min.js',
|
||||
// Math turns up in ordinary answers and KaTeX is small, so precaching it and
|
||||
// its fonts keeps formulas typeset offline. Mermaid is deliberately NOT
|
||||
// precached: at 3.5 MB it would re-download on every CACHE_NAME bump, a poor
|
||||
// trade for a library most sessions never touch. The cache-first rule below
|
||||
// picks it up the first time a diagram renders, which is also when it starts
|
||||
// mattering offline.
|
||||
'/static/lib/katex/katex.min.js',
|
||||
'/static/lib/katex/katex.min.css',
|
||||
...KATEX_FONTS,
|
||||
];
|
||||
|
||||
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
|
||||
// precached so the panel still opens with no network.
|
||||
const PANEL_PRECACHE = [
|
||||
// Image editor — galleryEditor.js and its js/editor/ graph.
|
||||
'/static/js/galleryEditor.js',
|
||||
'/static/js/editor/ai-inpaint.js?v=20260708match1',
|
||||
'/static/js/editor/ai-models.js',
|
||||
'/static/js/editor/ai-rembg.js',
|
||||
'/static/js/editor/ai-tool-runner.js',
|
||||
'/static/js/editor/ai-tools-misc.js',
|
||||
'/static/js/editor/build/controls.js?v=20260708match1',
|
||||
'/static/js/editor/build/popups.js',
|
||||
'/static/js/editor/build/right-panel.js',
|
||||
'/static/js/editor/build/toolbar.js?v=20260708sam3',
|
||||
'/static/js/editor/build/topbar.js',
|
||||
'/static/js/editor/build/transform-popup.js',
|
||||
'/static/js/editor/canvas-coords.js',
|
||||
'/static/js/editor/canvas-events.js',
|
||||
'/static/js/editor/canvas-transforms.js',
|
||||
'/static/js/editor/checkerboard.js',
|
||||
'/static/js/editor/clipboard-and-drop.js',
|
||||
'/static/js/editor/composite-helpers.js',
|
||||
'/static/js/editor/filters/blur.js',
|
||||
'/static/js/editor/filters/edge-feather.js',
|
||||
'/static/js/editor/fx/adj-popup.js',
|
||||
'/static/js/editor/fx/filter-string.js',
|
||||
'/static/js/editor/fx/histogram.js',
|
||||
'/static/js/editor/fx/pixel-pass.js',
|
||||
'/static/js/editor/harmonize-masks.js',
|
||||
'/static/js/editor/history-panel.js',
|
||||
'/static/js/editor/keyboard-shortcuts.js',
|
||||
'/static/js/editor/layer-helpers.js',
|
||||
'/static/js/editor/layer-panel.js',
|
||||
'/static/js/editor/mask-utils.js',
|
||||
'/static/js/editor/shortcuts-popover.js',
|
||||
'/static/js/editor/slider-ux.js',
|
||||
'/static/js/editor/snap.js',
|
||||
'/static/js/editor/state.js',
|
||||
'/static/js/editor/stroke-pipeline.js',
|
||||
'/static/js/editor/stroke-tool-sliders.js',
|
||||
'/static/js/editor/tools/clone.js',
|
||||
'/static/js/editor/tools/crop.js',
|
||||
'/static/js/editor/tools/flood-fill.js',
|
||||
'/static/js/editor/tools/lasso-mask.js',
|
||||
'/static/js/editor/tools/lasso.js',
|
||||
'/static/js/editor/tools/move.js',
|
||||
'/static/js/editor/tools/stroke.js',
|
||||
'/static/js/editor/tools/transform-drag.js',
|
||||
'/static/js/editor/tools/transform-handles.js',
|
||||
'/static/js/editor/tools/transform-session.js',
|
||||
'/static/js/editor/tools/wand.js',
|
||||
'/static/js/editor/wire-import.js',
|
||||
'/static/js/editor/wire-inpaint-controls.js?v=20260708match1',
|
||||
'/static/js/editor/wire-merge-buttons.js',
|
||||
'/static/js/editor/wire-selection-controls.js',
|
||||
'/static/js/editor/wire-topbar-menus.js',
|
||||
'/static/js/editor/wire-topbar-overflow.js',
|
||||
'/static/js/editor/wire-topbar.js',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
@@ -69,7 +164,7 @@ self.addEventListener('install', (e) => {
|
||||
// addAll is atomic — if any item fails, none are cached. Use individual
|
||||
// puts so a single 404 can't block the whole install.
|
||||
Promise.all(
|
||||
PRECACHE.map(url =>
|
||||
[...PRECACHE, ...PANEL_PRECACHE].map(url =>
|
||||
fetch(url, { cache: 'reload' })
|
||||
.then(res => res.ok ? cache.put(url, res) : null)
|
||||
.catch(() => null)
|
||||
|
||||
@@ -10,3 +10,28 @@ def test_mask_token_handles_short_values(monkeypatch):
|
||||
assert cli._mask_token("short") == "***"
|
||||
assert cli._mask_token("abcdef1234567890") == "abcdef…7890"
|
||||
assert cli._mask_token("short", reveal=True) == "short"
|
||||
|
||||
|
||||
def test_task_webhook_url_matches_live_route_and_escapes_path_parts(monkeypatch):
|
||||
make_core_db_stub(monkeypatch, models=["ScheduledTask"])
|
||||
cli = load_script("odysseus-webhook")
|
||||
|
||||
url = cli._task_webhook_url(
|
||||
"https://ody.example/",
|
||||
"task/with space",
|
||||
"token/with space",
|
||||
)
|
||||
|
||||
assert url == (
|
||||
"https://ody.example/api/tasks/task%2Fwith%20space/"
|
||||
"webhook/token%2Fwith%20space"
|
||||
)
|
||||
|
||||
|
||||
def test_default_task_webhook_url_uses_current_task_prefix(monkeypatch):
|
||||
make_core_db_stub(monkeypatch, models=["ScheduledTask"])
|
||||
cli = load_script("odysseus-webhook")
|
||||
|
||||
assert cli._task_webhook_url(None, "task-1", "secret") == (
|
||||
"http://localhost:7000/api/tasks/task-1/webhook/secret"
|
||||
)
|
||||
|
||||
@@ -817,6 +817,12 @@ const STUBS = new Map([
|
||||
bindMenuDismiss() {},
|
||||
},
|
||||
],
|
||||
[
|
||||
path.join(JS, 'appConfig.js'),
|
||||
{
|
||||
invalidateSettings() {},
|
||||
},
|
||||
],
|
||||
[
|
||||
path.join(JS, 'windowDrag.js'),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Pin the shared config cache in static/js/appConfig.js.
|
||||
|
||||
Background: /api/auth/settings was fetched independently by six modules and
|
||||
/api/tools by three (chatRenderer.js is imported under three different ?v=
|
||||
query strings, so it is three separate module instances) — 4 and 3 requests on
|
||||
one cold load. Beyond the redundant work, each caller could observe a different
|
||||
snapshot of the same object. appConfig.js gives them one promise each.
|
||||
|
||||
The two properties that matter are opposites, so both are tested here:
|
||||
concurrent and later callers must NOT refetch, and a caller after a write MUST
|
||||
see the new value — which only holds if every writer invalidates. The last test
|
||||
is a source scan that checks exactly that, since a forgotten invalidation
|
||||
serves a stale settings object for the rest of the session, which is worse than
|
||||
the duplicate fetches this replaces.
|
||||
|
||||
Driven through `node --input-type=module` so the real module runs, same idiom as
|
||||
test_esc_menu_stack_js.py. The module source is inlined rather than imported by
|
||||
path because the repo has no `"type": "module"` in package.json; appConfig.js
|
||||
has no imports of its own, so inlining is exact. `fetch` and `sessionStorage`
|
||||
are stubbed, so nothing here touches the network or depends on timing.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_MODULE = _REPO / "static" / "js" / "appConfig.js"
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
_SRC = _MODULE.read_text(encoding="utf-8") if _MODULE.exists() else ""
|
||||
|
||||
# Browser stand-ins. Every fetch is recorded and resolved from a queue the test
|
||||
# controls, so "how many requests went out" is an exact count, not a guess.
|
||||
_STUBS = r"""
|
||||
const calls = [];
|
||||
let responses = [];
|
||||
globalThis.__queue = (fn) => { responses.push(fn); };
|
||||
globalThis.fetch = (url, opts) => {
|
||||
calls.push([url, opts]);
|
||||
const next = responses.shift();
|
||||
if (!next) throw new Error('unexpected fetch: ' + url);
|
||||
return next();
|
||||
};
|
||||
globalThis.__calls = () => calls;
|
||||
globalThis.__json = (value) => () => Promise.resolve({ json: () => Promise.resolve(value) });
|
||||
globalThis.__fail = (msg) => () => Promise.reject(new Error(msg));
|
||||
|
||||
const store = new Map();
|
||||
globalThis.sessionStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => { store.set(k, String(v)); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
globalThis.__seedPrefetch = (value) => {
|
||||
store.set('ody-prefetch-settings', JSON.stringify(value));
|
||||
};
|
||||
globalThis.__prefetchLeft = () => store.has('ody-prefetch-settings');
|
||||
"""
|
||||
|
||||
|
||||
def _run(body: str) -> str:
|
||||
js = _STUBS + "\n" + _SRC + "\n" + body
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=js, capture_output=True, text=True, encoding="utf-8",
|
||||
cwd=str(_REPO), timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_concurrent_callers_share_one_request():
|
||||
# The startup case: several modules ask before the first response lands.
|
||||
body = """
|
||||
__queue(__json({ tts_enabled: true }));
|
||||
const [a, b, c] = await Promise.all([getSettings(), getSettings(), getSettings()]);
|
||||
console.log(JSON.stringify({
|
||||
requests: __calls().length,
|
||||
url: __calls()[0][0],
|
||||
credentials: __calls()[0][1].credentials,
|
||||
sameObject: a === b && b === c,
|
||||
value: a.tts_enabled,
|
||||
}));
|
||||
"""
|
||||
assert json.loads(_run(body)) == {
|
||||
"requests": 1,
|
||||
"url": "/api/auth/settings",
|
||||
"credentials": "same-origin",
|
||||
"sameObject": True,
|
||||
"value": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_later_caller_reuses_the_resolved_snapshot():
|
||||
# A panel opened long after boot must not re-request.
|
||||
body = """
|
||||
__queue(__json({ search_provider: 'brave' }));
|
||||
const first = await getSettings();
|
||||
const second = await getSettings();
|
||||
console.log(JSON.stringify({ requests: __calls().length, sameObject: first === second }));
|
||||
"""
|
||||
assert json.loads(_run(body)) == {"requests": 1, "sameObject": True}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_invalidate_forces_the_next_read_to_refetch():
|
||||
# The write path: save a setting, then read it back and see the new value.
|
||||
body = """
|
||||
__queue(__json({ tts_enabled: true }));
|
||||
__queue(__json({ tts_enabled: false }));
|
||||
const before = await getSettings();
|
||||
invalidateSettings();
|
||||
const after = await getSettings();
|
||||
console.log(JSON.stringify({
|
||||
requests: __calls().length,
|
||||
before: before.tts_enabled,
|
||||
after: after.tts_enabled,
|
||||
}));
|
||||
"""
|
||||
assert json.loads(_run(body)) == {"requests": 2, "before": True, "after": False}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_a_failed_fetch_does_not_poison_the_cache():
|
||||
# Plain `??=` memoisation would keep the rejected promise, so one blip at
|
||||
# boot would leave keybinds/TTS/search on defaults for the whole session.
|
||||
body = """
|
||||
__queue(__fail('offline'));
|
||||
__queue(__json({ tts_enabled: true }));
|
||||
let rejected = false;
|
||||
try { await getSettings(); } catch (e) { rejected = e.message === 'offline'; }
|
||||
const retry = await getSettings();
|
||||
console.log(JSON.stringify({
|
||||
rejected,
|
||||
requests: __calls().length,
|
||||
recovered: retry.tts_enabled,
|
||||
}));
|
||||
"""
|
||||
assert json.loads(_run(body)) == {"rejected": True, "requests": 2, "recovered": True}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_settings_and_tools_are_independent_slots():
|
||||
body = """
|
||||
__queue(__json({ tts_enabled: true }));
|
||||
__queue(__json({ tools: [{ id: 'web_search' }] }));
|
||||
await getSettings();
|
||||
const tools = await getTools();
|
||||
const toolsUrl = __calls()[1][0];
|
||||
invalidateSettings(); // must not drop the tools snapshot
|
||||
const toolsAgain = await getTools();
|
||||
console.log(JSON.stringify({
|
||||
requests: __calls().length,
|
||||
toolsUrl,
|
||||
sameObject: tools === toolsAgain,
|
||||
}));
|
||||
"""
|
||||
assert json.loads(_run(body)) == {
|
||||
"requests": 2,
|
||||
"toolsUrl": "/api/tools",
|
||||
"sameObject": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_login_prefetch_is_used_once_and_then_consumed():
|
||||
# login.html stashes a settings snapshot in sessionStorage just before it
|
||||
# redirects, so the first load after a login should issue no request at all.
|
||||
body = """
|
||||
__seedPrefetch({ tts_enabled: false, from: 'prefetch' });
|
||||
__queue(__json({ tts_enabled: true, from: 'network' }));
|
||||
const first = await getSettings();
|
||||
const consumed = !__prefetchLeft();
|
||||
invalidateSettings();
|
||||
const second = await getSettings();
|
||||
console.log(JSON.stringify({
|
||||
requests: __calls().length,
|
||||
first: first.from,
|
||||
consumed,
|
||||
second: second.from,
|
||||
}));
|
||||
"""
|
||||
assert json.loads(_run(body)) == {
|
||||
"requests": 1,
|
||||
"first": "prefetch",
|
||||
"consumed": True,
|
||||
"second": "network",
|
||||
}
|
||||
|
||||
|
||||
# ── Writers must invalidate ─────────────────────────────────────────────────
|
||||
|
||||
_WRITE_ENDPOINTS = {
|
||||
"/api/auth/settings": "invalidateSettings",
|
||||
"/api/tools": "invalidateTools",
|
||||
}
|
||||
# login.html is the pre-app login page: it has no module graph and its only call
|
||||
# is the prefetch GET, so it is not a writer and cannot import appConfig.js.
|
||||
_SCANNED = [_REPO / "static" / "app.js"] + sorted((_REPO / "static" / "js").rglob("*.js"))
|
||||
|
||||
|
||||
def _post_sites(source: str, endpoint: str):
|
||||
"""Yield the 1-based line of every fetch() to `endpoint` that is a POST."""
|
||||
for m in re.finditer(re.escape(f"'{endpoint}'"), source):
|
||||
window = source[m.start():m.start() + 240]
|
||||
if re.search(r"method:\s*'POST'", window):
|
||||
yield source[:m.start()].count("\n") + 1
|
||||
|
||||
|
||||
def test_every_settings_writer_invalidates_the_shared_cache():
|
||||
"""A POST that skips the invalidation serves a stale object for the session.
|
||||
|
||||
Checked by source scan rather than at runtime: the failure mode is a call
|
||||
site that was never wired up, which no unit test of the cache itself can
|
||||
see. `appConfig.js` itself is skipped — it is the cache, not a writer.
|
||||
"""
|
||||
missing = []
|
||||
for path in _SCANNED:
|
||||
if path.name == "appConfig.js":
|
||||
continue
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for endpoint, invalidator in _WRITE_ENDPOINTS.items():
|
||||
for line in _post_sites(source, endpoint):
|
||||
# The invalidation belongs in the same function as the POST;
|
||||
# accept it anywhere in the surrounding 20 lines either way.
|
||||
lines = source.splitlines()
|
||||
near = "\n".join(lines[max(0, line - 20):line + 20])
|
||||
if invalidator + "(" not in near:
|
||||
missing.append(f"{path.relative_to(_REPO)}:{line} POST {endpoint}")
|
||||
assert not missing, (
|
||||
"POST sites with no nearby cache invalidation — the UI will serve a "
|
||||
"stale snapshot after these writes:\n " + "\n ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_out_of_band_tool_change_is_not_undone_by_an_unrelated_panel_save():
|
||||
"""Admin > Tools must render authoritative state, not the startup snapshot.
|
||||
|
||||
The save posts the whole disabled-tool list rebuilt from the checkboxes, so
|
||||
a stale render turns any unrelated toggle into a lost update: a tool
|
||||
disabled out of band (manage_settings, another tab) comes back enabled.
|
||||
refreshAll() calls loadBuiltinTools() on every panel open, which is why the
|
||||
editor drops the shared entry before reading it.
|
||||
"""
|
||||
body = """
|
||||
// Boot: chatRenderer.js reads the tool list for the exec-fence regex.
|
||||
__queue(__json({ tools: [{ id: 'web_search', enabled: true }, { id: 'shell', enabled: true }] }));
|
||||
const boot = await getTools();
|
||||
|
||||
// Out of band, this page hearing nothing about it: web_search is disabled.
|
||||
__queue(__json({ tools: [{ id: 'web_search', enabled: false }, { id: 'shell', enabled: true }] }));
|
||||
|
||||
// Admin > Tools opens. loadBuiltinTools() invalidates, then reads.
|
||||
invalidateTools();
|
||||
const panel = await getTools();
|
||||
|
||||
// The user toggles one unrelated tool off. The save posts every unchecked
|
||||
// box, so the list is only right if the render was authoritative.
|
||||
const post = (snapshot) => {
|
||||
const boxes = snapshot.tools.map(t => ({ id: t.id, checked: t.enabled }));
|
||||
boxes.find(b => b.id === 'shell').checked = false;
|
||||
return boxes.filter(b => !b.checked).map(b => b.id);
|
||||
};
|
||||
|
||||
console.log(JSON.stringify({
|
||||
requests: __calls().length,
|
||||
posted: post(panel),
|
||||
postedFromStaleSnapshot: post(boot),
|
||||
}));
|
||||
"""
|
||||
assert json.loads(_run(body)) == {
|
||||
"requests": 2,
|
||||
# web_search stays disabled, which is the point.
|
||||
"posted": ["web_search", "shell"],
|
||||
# What the page-lifetime snapshot would have posted: web_search silently
|
||||
# re-enabled by a toggle that had nothing to do with it.
|
||||
"postedFromStaleSnapshot": ["shell"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_out_of_band_tool_change_after_panel_open_is_preserved_on_save():
|
||||
"""Saving must merge the user's edit onto a fresh authoritative snapshot."""
|
||||
body = """
|
||||
// Panel opens while both tools are enabled.
|
||||
__queue(__json({ tools: [
|
||||
{ id: 'web_search', enabled: true },
|
||||
{ id: 'shell', enabled: true },
|
||||
] }));
|
||||
invalidateTools();
|
||||
const panel = await getTools();
|
||||
|
||||
// Another tab disables web_search after this panel has already rendered.
|
||||
__queue(__json({ tools: [
|
||||
{ id: 'web_search', enabled: false },
|
||||
{ id: 'shell', enabled: true },
|
||||
] }));
|
||||
|
||||
// The user only disables shell. Saving refreshes the authoritative state
|
||||
// first and applies that one intended change on top of it.
|
||||
invalidateTools();
|
||||
const latest = await getTools();
|
||||
const state = new Map(latest.tools.map(t => [t.id, !!t.enabled]));
|
||||
state.set('shell', false);
|
||||
|
||||
const disabled = Array.from(state.entries())
|
||||
.filter(([, enabled]) => !enabled)
|
||||
.map(([id]) => id);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
panelWebSearchEnabled: panel.tools.find(t => t.id === 'web_search').enabled,
|
||||
requests: __calls().length,
|
||||
disabled,
|
||||
}));
|
||||
"""
|
||||
assert json.loads(_run(body)) == {
|
||||
"panelWebSearchEnabled": True,
|
||||
"requests": 2,
|
||||
"disabled": ["web_search", "shell"],
|
||||
}
|
||||
|
||||
|
||||
def test_admin_tool_save_refreshes_before_full_state_post():
|
||||
"""Pin the lost-update guard in the Admin Tools full-list writer."""
|
||||
source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"async function _saveToolState\(changes\) \{(.*?)\n \}\n"
|
||||
r" function _updateCatCounter",
|
||||
source,
|
||||
re.S,
|
||||
)
|
||||
assert match, "_saveToolState(changes) not found in static/js/admin.js"
|
||||
|
||||
body = match.group(1)
|
||||
invalidate = body.find("invalidateTools()")
|
||||
refresh = body.find("getTools()")
|
||||
post = body.find("fetch('/api/tools'")
|
||||
|
||||
assert -1 not in (invalidate, refresh, post)
|
||||
assert invalidate < refresh < post, (
|
||||
"Admin Tools must invalidate and refresh authoritative tool state before "
|
||||
"posting the endpoint's full disabled-tools replacement list"
|
||||
)
|
||||
assert "for (const change of changes)" in body
|
||||
|
||||
|
||||
def test_the_admin_tools_editor_does_not_read_a_cached_snapshot():
|
||||
"""Pin the invalidate-before-read in loadBuiltinTools().
|
||||
|
||||
A source scan because the failure is an ordering in a call site, not
|
||||
behaviour of the cache: getTools() is doing exactly its job either way.
|
||||
"""
|
||||
source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
|
||||
match = re.search(r"\nasync function loadBuiltinTools\(\) \{\n(.*?)\n\}\n", source, re.S)
|
||||
assert match, "loadBuiltinTools() not found in static/js/admin.js"
|
||||
body = match.group(1)
|
||||
read = body.find("getTools(")
|
||||
assert read != -1, "loadBuiltinTools() no longer reads the shared tool cache"
|
||||
assert "invalidateTools(" in body[:read], (
|
||||
"loadBuiltinTools() reads the shared /api/tools snapshot without dropping "
|
||||
"it first, so a reopened panel can render tool state that changed out of "
|
||||
"band and re-post it on the next unrelated toggle"
|
||||
)
|
||||
|
||||
|
||||
def test_appconfig_is_precached_by_the_service_worker():
|
||||
"""PRECACHE is hand-maintained; a module missing from it breaks offline."""
|
||||
sw = (_REPO / "static" / "sw.js").read_text(encoding="utf-8")
|
||||
assert "'/static/js/appConfig.js'" in sw
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Regression coverage for the assistant turn an approved-action replay appends.
|
||||
|
||||
Anthropic's Messages API rejects a non-final assistant message whose content is
|
||||
empty, so the resumed turn after a tool approval used to fail before the model
|
||||
ever saw the sealed result. The replay injects its result with no assistant
|
||||
prose for that round, which is the only path that produced such a turn.
|
||||
"""
|
||||
|
||||
import src.llm_core as llm_core
|
||||
from src.agent_loop import _append_tool_results
|
||||
|
||||
_RESULT = "bash: ok\nhello"
|
||||
_RECORD = {
|
||||
"tool_name": "bash",
|
||||
"content": "printf hello",
|
||||
"result": {"output": "hello", "exit_code": 0},
|
||||
"text": _RESULT,
|
||||
}
|
||||
|
||||
|
||||
def _replay_messages(round_response="", round_reasoning=""):
|
||||
"""Mirror the approved-action injection in stream_agent_loop."""
|
||||
messages = [
|
||||
{"role": "system", "content": "system preface"},
|
||||
{"role": "user", "content": "run the command and summarise it"},
|
||||
]
|
||||
_append_tool_results(
|
||||
messages,
|
||||
round_response,
|
||||
[],
|
||||
[_RESULT],
|
||||
[_RESULT],
|
||||
False,
|
||||
0,
|
||||
round_reasoning=round_reasoning,
|
||||
tool_result_records=[_RECORD],
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
def _empty_assistant_turns(messages):
|
||||
return [
|
||||
index
|
||||
for index, message in enumerate(messages)
|
||||
if message.get("role") == "assistant"
|
||||
and not str(message.get("content") or "").strip()
|
||||
and not message.get("tool_calls")
|
||||
]
|
||||
|
||||
|
||||
def test_replay_appends_no_empty_assistant_turn():
|
||||
assert _empty_assistant_turns(_replay_messages()) == []
|
||||
|
||||
|
||||
def test_replay_payload_is_a_single_non_empty_user_turn_for_anthropic():
|
||||
# Asserting the whole sequence rather than scanning a slice: once the empty
|
||||
# spacer is gone the payload is one message, so a "no offenders in
|
||||
# chat[:-1]" check would pass without inspecting anything.
|
||||
sanitized = llm_core._sanitize_llm_messages(_replay_messages())
|
||||
payload = llm_core._build_anthropic_payload(
|
||||
"claude-sonnet-5", sanitized, 0.2, 512
|
||||
)
|
||||
chat = payload["messages"]
|
||||
assert [message["role"] for message in chat] == ["user"]
|
||||
assert all(str(message.get("content") or "").strip() for message in chat)
|
||||
|
||||
|
||||
def test_replay_merges_tool_output_after_the_request_with_its_fence_intact():
|
||||
# Dropping the empty assistant turn leaves two adjacent user messages, which
|
||||
# the sanitizer merges. Pin that shape: the tool output must still sit
|
||||
# behind its untrusted fence and must not precede the operator's request.
|
||||
sanitized = llm_core._sanitize_llm_messages(_replay_messages())
|
||||
user_turns = [m for m in sanitized if m.get("role") == "user"]
|
||||
assert len(user_turns) == 1
|
||||
merged = user_turns[0]["content"]
|
||||
assert merged.index("run the command") < merged.index("UNTRUSTED SOURCE DATA")
|
||||
assert merged.index("UNTRUSTED SOURCE DATA") < merged.index("hello")
|
||||
|
||||
|
||||
def test_a_round_with_prose_still_appends_its_assistant_turn():
|
||||
messages = _replay_messages(round_response="running that now")
|
||||
assistants = [m for m in messages if m.get("role") == "assistant"]
|
||||
assert [m["content"] for m in assistants] == ["running that now"]
|
||||
|
||||
|
||||
def test_reasoning_only_round_keeps_its_carrier_and_its_known_empty_content():
|
||||
"""Characterises the one empty-content turn this change deliberately leaves.
|
||||
|
||||
A round with reasoning and no prose still appends its carrier, and that
|
||||
carrier's content is still "", which Anthropic would still reject. Dropping
|
||||
it would lose the reasoning DeepSeek thinking mode requires on the next
|
||||
request, and the approval replay never takes this path because it passes no
|
||||
reasoning. Left alone on purpose; this test makes the gap visible instead of
|
||||
silent, and should be updated by whoever closes it.
|
||||
"""
|
||||
messages = _replay_messages(round_reasoning="thinking about it")
|
||||
assistants = [m for m in messages if m.get("role") == "assistant"]
|
||||
assert len(assistants) == 1
|
||||
assert assistants[0].get("reasoning_content") == "thinking about it"
|
||||
assert assistants[0]["content"] == ""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user