mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 13:52:24 +02:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b8829fb2 | ||
|
|
895bf896e3 | ||
|
|
cc42f38a89 | ||
|
|
d5514da3ab | ||
|
|
67e08cce1b | ||
|
|
2e2bb5231e | ||
|
|
f7cbc885c1 | ||
|
|
cee319050c | ||
|
|
0dd70a7556 | ||
|
|
9c71948376 | ||
|
|
18991d6f67 | ||
|
|
79b891c7ee | ||
|
|
60bed54703 | ||
|
|
443f7d2963 | ||
|
|
2c394704c6 | ||
|
|
d401e806d4 | ||
|
|
105a7c0d96 | ||
|
|
73a4b10642 | ||
|
|
94cf119b11 | ||
|
|
7a138e8a3f | ||
|
|
2b72531eaa | ||
|
|
58b2a4bfa9 | ||
|
|
fd50561af6 | ||
|
|
1b09c568d8 | ||
|
|
2811c7e815 | ||
|
|
1f216cfd0e | ||
|
|
b715b81ad0 | ||
|
|
05442a9945 | ||
|
|
2295504141 | ||
|
|
329f9d298d | ||
|
|
fef0e6f3c0 | ||
|
|
f9235ebbf1 | ||
|
|
49e4e55d2c | ||
|
|
b2789d04fb | ||
|
|
a6bc86e331 | ||
|
|
c4369305f0 |
@@ -76,6 +76,14 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# Change this if another local service already uses 7000 (macOS AirPlay often does).
|
# Change this if another local service already uses 7000 (macOS AirPlay often does).
|
||||||
# APP_PORT=7000
|
# APP_PORT=7000
|
||||||
|
|
||||||
|
# Optional HTTP address advertised in companion/mobile pairing codes. Set this
|
||||||
|
# when Docker would otherwise advertise a container address or loopback. Use a
|
||||||
|
# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local
|
||||||
|
# name that the phone can reach. HTTPS and public hostnames are not supported
|
||||||
|
# by the current companion client. Do not include credentials, a path, query,
|
||||||
|
# or fragment.
|
||||||
|
# COMPANION_BASE_URL=http://192.168.1.50:7000
|
||||||
|
|
||||||
# Development-only auth bypass for loopback requests.
|
# Development-only auth bypass for loopback requests.
|
||||||
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
|
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
|
||||||
# LOCALHOST_BYPASS=false
|
# LOCALHOST_BYPASS=false
|
||||||
@@ -151,6 +159,21 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# Local HTTP setups may use the callback URL inferred by the application.
|
# 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
|
# 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
|
# Misc
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Fixes #
|
|||||||
- [ ] This PR targets `dev`
|
- [ ] This PR targets `dev`
|
||||||
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
|
- [ ] 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 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
|
## How to Test
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
|
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const problems = [];
|
const descriptionProblems = [];
|
||||||
|
|
||||||
// 1. Summary must be filled in.
|
// 1. Summary must be filled in.
|
||||||
if (section('Summary').length < 20) {
|
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
|
// 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 linkedSection = section('Linked Issue');
|
||||||
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
|
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
|
||||||
if (!linkedSection || !hasIssueRef) {
|
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.
|
// 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] ?? '';
|
const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? '';
|
||||||
if (!/- \[x\]/i.test(typeBlock)) {
|
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.
|
// 4. Duplicate-search checklist item must be checked.
|
||||||
if (!/- \[x\] I searched/i.test(body)) {
|
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.
|
// 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.
|
// code block — so we only require non-trivial content, not a specific shape.
|
||||||
const howTo = section('How to Test');
|
const howTo = section('How to Test');
|
||||||
if (howTo.length < 30) {
|
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 ──────────────────────────────────────────────────────────────
|
// ── Comment ──────────────────────────────────────────────────────────────
|
||||||
@@ -62,22 +138,43 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
});
|
});
|
||||||
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
|
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
|
||||||
|
|
||||||
if (problems.length === 0) {
|
if (descriptionProblems.length === 0 && evidenceGaps.length === 0) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const commentBody = [
|
const commentLines = [MARKER];
|
||||||
MARKER,
|
if (descriptionProblems.length > 0) {
|
||||||
'⚠️ **PR description — action needed**',
|
commentLines.push(
|
||||||
'',
|
'⚠️ **PR description — action needed**',
|
||||||
'The following required sections are missing or incomplete. Please update the PR description to address them:',
|
'',
|
||||||
'',
|
'The following required sections are missing or incomplete. Please update the PR description to address them:',
|
||||||
problems.map(p => `- ${p}`).join('\n'),
|
'',
|
||||||
|
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._',
|
'_This comment updates automatically when the description or changed files change._',
|
||||||
].join('\n');
|
);
|
||||||
|
const commentBody = commentLines.join('\n');
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
|
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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 404) return false;
|
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;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function swapLabel(num, add, remove) {
|
async function setLabel(name, wanted) {
|
||||||
if (await labelExists(add)) {
|
if (wanted && await labelExists(name)) {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
// Fail soft on a token that can't write labels so a label permission
|
// Fail soft on a token that can't write labels so a label permission
|
||||||
// problem never masks the actual description verdict.
|
// problem never masks the actual description verdict.
|
||||||
if (e.status !== 403) throw e;
|
if (e.status !== 403 && e.status !== 404) throw e;
|
||||||
core.warning(`Could not add "${add}" — token lacks label write here; skipping.`);
|
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 {
|
} 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: prNum, name });
|
||||||
try {
|
} catch (e) {
|
||||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove });
|
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
|
||||||
} catch (e) {
|
}
|
||||||
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (problems.length === 0) {
|
const descriptionComplete = descriptionProblems.length === 0;
|
||||||
await swapLabel(prNum, 'ready for review', 'needs work');
|
const evidenceComplete = evidenceGaps.length === 0;
|
||||||
} else {
|
const isDraft = Boolean(context.payload.pull_request.draft);
|
||||||
await swapLabel(prNum, 'needs work', 'ready for review');
|
await setLabel(
|
||||||
core.setFailed(`PR description has ${problems.length} issue(s) — see bot comment for details.`);
|
'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
|
# 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.
|
# code runs) and the scripts only read context.payload and call the GitHub API.
|
||||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
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.
|
# 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
|
# Note: modifying a PR's labels/comments needs pull-requests:write even though the
|
||||||
@@ -59,12 +63,14 @@ jobs:
|
|||||||
|
|
||||||
check-mergeable:
|
check-mergeable:
|
||||||
name: Flag unmergeable PRs
|
name: Flag unmergeable PRs
|
||||||
|
needs: check-description
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
issues: write
|
issues: write
|
||||||
# Skip bots: they open PRs programmatically and have their own process.
|
# Run after description validation failures, but never from an obsolete
|
||||||
if: github.event.pull_request.user.type != 'Bot'
|
# workflow run canceled by a newer PR event.
|
||||||
|
if: ${{ !cancelled() && github.event.pull_request.user.type != 'Bot' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -59,7 +59,12 @@ Help is welcome. The best entry points are fresh-install testing, provider setup
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly.
|
||||||
|
|
||||||
|
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||||
|
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||||
|
|
||||||
|
Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ Non-admin defaults are in `core/auth.py:DEFAULT_PRIVILEGES`. Tool enforcement is
|
|||||||
|
|
||||||
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
|
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
|
||||||
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
|
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
|
||||||
- **Reserved usernames:** `internal-tool`, `api`, `demo`, `system` cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
|
- **Reserved usernames:** request sentinels and the Default/Local storage owner cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
|
||||||
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
|
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
|
||||||
- **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
|
- **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ from core.constants import (
|
|||||||
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
|
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
|
||||||
)
|
)
|
||||||
from core.database import SessionLocal, ApiToken
|
from core.database import SessionLocal, ApiToken
|
||||||
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
|
from core.middleware import (
|
||||||
|
SecurityHeadersMiddleware,
|
||||||
|
get_application_route_path,
|
||||||
|
is_cors_preflight,
|
||||||
|
path_is_route_or_child,
|
||||||
|
with_asgi_root_path,
|
||||||
|
)
|
||||||
from core.auth import AuthManager, normalize_known_username
|
from core.auth import AuthManager, normalize_known_username
|
||||||
from core.exceptions import (
|
from core.exceptions import (
|
||||||
SessionNotFoundError, InvalidFileUploadError,
|
SessionNotFoundError, InvalidFileUploadError,
|
||||||
@@ -78,6 +84,7 @@ import bcrypt as _bcrypt
|
|||||||
|
|
||||||
from src.app_helpers import abs_join, serve_html_with_nonce
|
from src.app_helpers import abs_join, serve_html_with_nonce
|
||||||
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
||||||
|
from src.owner_identity import auth_disabled
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
# ========= LOGGING =========
|
# ========= LOGGING =========
|
||||||
@@ -248,7 +255,7 @@ from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
|||||||
|
|
||||||
auth_manager = AuthManager()
|
auth_manager = AuthManager()
|
||||||
app.state.auth_manager = auth_manager
|
app.state.auth_manager = auth_manager
|
||||||
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() != "false"
|
AUTH_ENABLED = not auth_disabled()
|
||||||
LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true"
|
LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true"
|
||||||
if LOCALHOST_BYPASS:
|
if LOCALHOST_BYPASS:
|
||||||
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
|
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
|
||||||
@@ -284,7 +291,7 @@ if AUTH_ENABLED:
|
|||||||
def _is_auth_exempt(path: str) -> bool:
|
def _is_auth_exempt(path: str) -> bool:
|
||||||
if path in AUTH_EXEMPT_EXACT:
|
if path in AUTH_EXEMPT_EXACT:
|
||||||
return True
|
return True
|
||||||
if any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES):
|
if any(path_is_route_or_child(path, p) for p in AUTH_EXEMPT_PREFIXES):
|
||||||
return True
|
return True
|
||||||
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
|
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
|
||||||
|
|
||||||
@@ -355,7 +362,7 @@ if AUTH_ENABLED:
|
|||||||
|
|
||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
path = request.url.path
|
path = get_application_route_path(request.scope)
|
||||||
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
|
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
|
||||||
# carries no credentials by design and must reach CORSMiddleware to be
|
# carries no credentials by design and must reach CORSMiddleware to be
|
||||||
# answered. AuthMiddleware is the outermost middleware, so gating the
|
# answered. AuthMiddleware is the outermost middleware, so gating the
|
||||||
@@ -399,7 +406,10 @@ if AUTH_ENABLED:
|
|||||||
if not auth_manager.is_configured:
|
if not auth_manager.is_configured:
|
||||||
# No users yet — redirect to login for first-time setup
|
# No users yet — redirect to login for first-time setup
|
||||||
if not path.startswith("/api/"):
|
if not path.startswith("/api/"):
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(
|
||||||
|
url=with_asgi_root_path(request.scope, "/login"),
|
||||||
|
status_code=302,
|
||||||
|
)
|
||||||
return JSONResponse(status_code=401, content={"error": "Setup required"})
|
return JSONResponse(status_code=401, content={"error": "Setup required"})
|
||||||
|
|
||||||
# --- Bearer token auth (API tokens for external integrations) ---
|
# --- Bearer token auth (API tokens for external integrations) ---
|
||||||
@@ -461,7 +471,10 @@ if AUTH_ENABLED:
|
|||||||
if not auth_manager.validate_token(token):
|
if not auth_manager.validate_token(token):
|
||||||
if path.startswith("/api/"):
|
if path.startswith("/api/"):
|
||||||
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(
|
||||||
|
url=with_asgi_root_path(request.scope, "/login"),
|
||||||
|
status_code=302,
|
||||||
|
)
|
||||||
|
|
||||||
# Attach current username to request state for downstream routes
|
# Attach current username to request state for downstream routes
|
||||||
request.state.current_user = auth_manager.get_username_for_token(token)
|
request.state.current_user = auth_manager.get_username_for_token(token)
|
||||||
@@ -630,13 +643,24 @@ app.include_router(auth_router)
|
|||||||
|
|
||||||
@app.post("/api/activity/heartbeat")
|
@app.post("/api/activity/heartbeat")
|
||||||
async def activity_heartbeat():
|
async def activity_heartbeat():
|
||||||
from src.interactive_gate import mark_browser_activity
|
from src.interactive_gate import (
|
||||||
|
mark_browser_activity,
|
||||||
|
maybe_stop_background_tasks_for_heartbeat,
|
||||||
|
)
|
||||||
|
|
||||||
await mark_browser_activity()
|
await mark_browser_activity()
|
||||||
|
|
||||||
async def _stop_background():
|
async def _stop_background():
|
||||||
try:
|
try:
|
||||||
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
|
await maybe_stop_background_tasks_for_heartbeat(
|
||||||
|
task_scheduler.stop_background_tasks_for_foreground
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
|
logging.getLogger("app.foreground_gate").debug(
|
||||||
|
"heartbeat task stop failed",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.create_task(_stop_background())
|
asyncio.create_task(_stop_background())
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ cat > "$APP/Contents/MacOS/$APP_NAME.tmpl" <<'LAUNCHER'
|
|||||||
INSTALL_DIR="__INSTALL_DIR__"
|
INSTALL_DIR="__INSTALL_DIR__"
|
||||||
PORT="__PORT__"
|
PORT="__PORT__"
|
||||||
URL="http://127.0.0.1:${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"
|
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
||||||
|
|
||||||
UVICORN="$INSTALL_DIR/venv/bin/uvicorn"
|
UVICORN="$INSTALL_DIR/venv/bin/uvicorn"
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ units so the route layer stays thin and the logic is directly testable.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import uuid
|
import uuid
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
@@ -20,6 +23,102 @@ PAIRING_VERSION = 1
|
|||||||
COMPANION_SCOPE = "chat"
|
COMPANION_SCOPE = "chat"
|
||||||
|
|
||||||
|
|
||||||
|
_COMPANION_IPV4_NETWORKS = tuple(
|
||||||
|
ipaddress.ip_network(cidr)
|
||||||
|
for cidr in (
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"100.64.0.0/10",
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"169.254.0.0/16",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_companion_client_host(host: str) -> bool:
|
||||||
|
"""Match the host forms supported by the current v1 Expo client."""
|
||||||
|
if not host or len(host) > 253 or not host.isascii() or "%" in host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
labels = host.split(".")
|
||||||
|
if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels):
|
||||||
|
return False
|
||||||
|
if any(label.startswith("xn--") for label in labels):
|
||||||
|
return False
|
||||||
|
# WHATWG URL parsers treat a decimal or ``0x`` single-label hostname
|
||||||
|
# as an IPv4 number even though Python's strict ``ipaddress`` parser
|
||||||
|
# rejects that spelling. The v1 client interpolates this host back
|
||||||
|
# into a URL, so accepting e.g. ``134744072`` would make the phone send
|
||||||
|
# its bearer token to public 8.8.8.8. Keep DNS labels unambiguous.
|
||||||
|
if len(labels) == 1 and (
|
||||||
|
labels[0].isdigit()
|
||||||
|
or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local")
|
||||||
|
|
||||||
|
return isinstance(address, ipaddress.IPv4Address) and any(
|
||||||
|
address in network for network in _COMPANION_IPV4_NETWORKS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_companion_base_url(value: str) -> tuple[str, int]:
|
||||||
|
"""Validate a v1 companion address and return its legacy (host, port).
|
||||||
|
|
||||||
|
The deployed client understands only HTTP plus a LAN-style host and port.
|
||||||
|
Reject anything outside that exact contract instead of advertising a URL
|
||||||
|
the client would reject, downgrade, or interpret differently.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||||
|
if not value.isascii():
|
||||||
|
raise ValueError("COMPANION_BASE_URL must contain only ASCII characters")
|
||||||
|
if any(
|
||||||
|
ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"}
|
||||||
|
for char in value
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"COMPANION_BASE_URL contains a forbidden character"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc
|
||||||
|
|
||||||
|
host = parsed.hostname
|
||||||
|
if parsed.scheme.lower() != "http" or not parsed.netloc or not host:
|
||||||
|
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ValueError("COMPANION_BASE_URL must not contain credentials")
|
||||||
|
if parsed.path or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment")
|
||||||
|
if port is not None and not 1 <= port <= 65535:
|
||||||
|
raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535")
|
||||||
|
if not _valid_companion_client_host(host):
|
||||||
|
raise ValueError("COMPANION_BASE_URL host is not supported by companion v1")
|
||||||
|
|
||||||
|
netloc = f"{host}:{port}" if port is not None else host
|
||||||
|
origin = f"http://{netloc}"
|
||||||
|
if value != origin:
|
||||||
|
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
||||||
|
return host, port or 80
|
||||||
|
|
||||||
|
|
||||||
|
def configured_companion_origin() -> tuple[str, int] | None:
|
||||||
|
"""Return the validated operator-configured v1 address, if any."""
|
||||||
|
value = os.environ.get("COMPANION_BASE_URL")
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return parse_companion_base_url(value)
|
||||||
|
|
||||||
|
|
||||||
def default_port() -> int:
|
def default_port() -> int:
|
||||||
"""Best guess at the port the server is reachable on. Callers that know the
|
"""Best guess at the port the server is reachable on. Callers that know the
|
||||||
real request port should pass it explicitly."""
|
real request port should pass it explicitly."""
|
||||||
|
|||||||
+23
-8
@@ -23,7 +23,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
|
|
||||||
from companion import pairing as _pairing
|
from companion import pairing as _pairing
|
||||||
|
|
||||||
@@ -113,8 +113,9 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
The stock /api/models route scopes to get_current_user, which for a
|
The stock /api/models route scopes to get_current_user, which for a
|
||||||
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
|
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
|
||||||
scope to the token's real owner instead, plus legacy null-owner shared
|
scope to the token's real owner instead, plus legacy null-owner shared
|
||||||
rows -- the same rule as owner_filter. Read-only; never returns api_key
|
rows -- the same rule as owner_filter. Explicit auth-disabled mode keeps
|
||||||
material.
|
the stock route's single-user all-endpoints view. Read-only; never
|
||||||
|
returns api_key material.
|
||||||
"""
|
"""
|
||||||
require_models_scope(request)
|
require_models_scope(request)
|
||||||
import json as _json
|
import json as _json
|
||||||
@@ -123,6 +124,11 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
from src.endpoint_resolver import build_chat_url
|
from src.endpoint_resolver import build_chat_url
|
||||||
|
|
||||||
owner = token_owner(request)
|
owner = token_owner(request)
|
||||||
|
single_user_mode = (
|
||||||
|
owner is None
|
||||||
|
and not getattr(request.state, "api_token", False)
|
||||||
|
and _auth_disabled()
|
||||||
|
)
|
||||||
out = []
|
out = []
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -133,7 +139,7 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
if owner:
|
if owner:
|
||||||
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
||||||
for ep in q.all():
|
for ep in q.all():
|
||||||
if not owner_can_see(ep.owner, owner):
|
if not single_user_mode and not owner_can_see(ep.owner, owner):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
|
model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
|
||||||
@@ -194,19 +200,27 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
the code works immediately, no restart. `?format=json` returns the
|
the code works immediately, no restart. `?format=json` returns the
|
||||||
payload for an in-app pairing screen."""
|
payload for an in-app pairing screen."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
try:
|
||||||
|
configured_origin = _pairing.configured_companion_origin()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(500, str(exc)) from None
|
||||||
owner = get_current_user(request)
|
owner = get_current_user(request)
|
||||||
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
|
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
|
||||||
token_id, raw_token = mint_pairing_token(owner, invalidate)
|
token_id, raw_token = mint_pairing_token(owner, invalidate)
|
||||||
|
|
||||||
hosts = _pairing.lan_ip_candidates()
|
if configured_origin:
|
||||||
host = hosts[0] if hosts else "127.0.0.1"
|
host, port = configured_origin
|
||||||
port = request.url.port or _pairing.default_port()
|
hosts = [host]
|
||||||
|
else:
|
||||||
|
hosts = _pairing.lan_ip_candidates()
|
||||||
|
host = hosts[0] if hosts else "127.0.0.1"
|
||||||
|
port = request.url.port or _pairing.default_port()
|
||||||
payload = _pairing.pairing_payload(host, port, raw_token)
|
payload = _pairing.pairing_payload(host, port, raw_token)
|
||||||
qr = _pairing.pairing_qr_png_data_uri(payload)
|
qr = _pairing.pairing_qr_png_data_uri(payload)
|
||||||
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
|
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
|
||||||
|
|
||||||
if (request.query_params.get("format") or "").lower() == "json":
|
if (request.query_params.get("format") or "").lower() == "json":
|
||||||
return {
|
response = {
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
"token": raw_token,
|
"token": raw_token,
|
||||||
@@ -215,6 +229,7 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
"payload": payload,
|
"payload": payload,
|
||||||
"qr": qr if qr_ok else None,
|
"qr": qr if qr_ok else None,
|
||||||
}
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
payload_json = _json.dumps(payload, separators=(",", ":"))
|
payload_json = _json.dumps(payload, separators=(",", ":"))
|
||||||
|
|||||||
+9
-16
@@ -20,7 +20,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
|
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
|
||||||
from core.middleware import INTERNAL_TOOL_USER # noqa: E402
|
|
||||||
|
|
||||||
DEFAULT_PRIVILEGES = {
|
DEFAULT_PRIVILEGES = {
|
||||||
"can_use_agent": True,
|
"can_use_agent": True,
|
||||||
@@ -49,24 +48,18 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
|||||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
ADMIN_PRIVILEGES["block_all_models"] = False
|
||||||
|
|
||||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
||||||
|
from src.owner_identity import RESERVED_AUTH_USERNAMES
|
||||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
# Usernames the auth + middleware layer reserves for request sentinels and
|
||||||
# sentinels; they must never belong to a real account. The most dangerous is
|
# internal storage owners; they must never belong to a real login account.
|
||||||
# "internal-tool": `core.middleware.require_admin` treats any request whose
|
# "internal-tool" is the most dangerous because `core.middleware.require_admin`
|
||||||
# `current_user == "internal-tool"` as the in-process tool loopback and grants
|
# treats it as the in-process tool loopback. "api" collides with bearer-token
|
||||||
# admin, and because the cookie auth path sets `current_user` to the raw
|
# attribution. "demo"/"system" are synthetic owners already special-cased by
|
||||||
# username, an account literally named "internal-tool" would be silently
|
# scheduler/assistant/research paths. The Default/Local owner is a storage
|
||||||
# treated as an admin by every `require_admin`-gated route. "api" collides with
|
# bucket for explicit auth-disabled no-login mode, not a login username.
|
||||||
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
|
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
|
||||||
# synthetic-owner set the rest of the codebase already special-cases (see
|
|
||||||
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
|
|
||||||
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
|
|
||||||
# of those names would be denied an assistant and inconsistently owner-scoped.
|
|
||||||
# Refuse to create or rename into any of them so the sentinels can't be
|
|
||||||
# impersonated. (Keep this in sync with that synthetic-owner set.)
|
|
||||||
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
||||||
|
|||||||
+19
-2
@@ -1491,8 +1491,25 @@ def _migrate_assign_legacy_owner():
|
|||||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||||
prefs = _json.load(f)
|
prefs = _json.load(f)
|
||||||
if "_users" not in prefs and prefs:
|
if "_users" not in prefs and prefs:
|
||||||
# Flat format → nest under admin user
|
# Flat format → nest ordinary preferences under the admin
|
||||||
new_prefs = {"_users": {admin_user: prefs}}
|
# user. Foreground fallback is an explicit per-owner opt-in,
|
||||||
|
# so auth-disabled consent must remain inert at the flat root
|
||||||
|
# rather than becoming consent for the first named owner.
|
||||||
|
foreground_keys = {
|
||||||
|
"foreground_fallback_enabled",
|
||||||
|
"foreground_model_fallbacks",
|
||||||
|
}
|
||||||
|
named_prefs = {
|
||||||
|
key: value
|
||||||
|
for key, value in prefs.items()
|
||||||
|
if key not in foreground_keys
|
||||||
|
}
|
||||||
|
new_prefs = {
|
||||||
|
key: prefs[key]
|
||||||
|
for key in foreground_keys
|
||||||
|
if key in prefs
|
||||||
|
}
|
||||||
|
new_prefs["_users"] = {admin_user: named_prefs}
|
||||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||||
_json.dump(new_prefs, f, indent=2)
|
_json.dump(new_prefs, f, indent=2)
|
||||||
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
|
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
|
||||||
|
|||||||
+29
-3
@@ -3,10 +3,14 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
from starlette.routing import get_route_path
|
||||||
|
|
||||||
|
from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
|
||||||
|
|
||||||
|
|
||||||
# Per-process token that lets the in-app tool layer hit admin-gated
|
# Per-process token that lets the in-app tool layer hit admin-gated
|
||||||
@@ -15,8 +19,30 @@ from starlette.responses import Response
|
|||||||
# same value from this module. Never persisted or exposed externally.
|
# same value from this module. Never persisted or exposed externally.
|
||||||
INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
|
INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
|
||||||
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
||||||
# Pseudo-username on in-process tool-loopback requests; require_admin trusts it and it is reserved.
|
|
||||||
INTERNAL_TOOL_USER = "internal-tool"
|
|
||||||
|
def get_application_route_path(scope: Mapping[str, object]) -> str:
|
||||||
|
"""Return the application-relative path used by Starlette routing.
|
||||||
|
|
||||||
|
Uvicorn prefixes ``scope["path"]`` with a configured ASGI ``root_path``;
|
||||||
|
Starlette removes that prefix before matching routes. Middleware policy
|
||||||
|
must use the same path form or a deployment prefix can change which policy
|
||||||
|
applies to an otherwise unchanged application route.
|
||||||
|
"""
|
||||||
|
return get_route_path(scope)
|
||||||
|
|
||||||
|
|
||||||
|
def with_asgi_root_path(scope: Mapping[str, object], path: str) -> str:
|
||||||
|
"""Prefix an application path for a client-facing redirect target."""
|
||||||
|
root_path = scope.get("root_path", "")
|
||||||
|
if not isinstance(root_path, str) or not root_path:
|
||||||
|
return path
|
||||||
|
return f"{root_path.rstrip('/')}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def path_is_route_or_child(path: str, prefix: str) -> bool:
|
||||||
|
"""Return whether ``path`` is exactly ``prefix`` or below that route."""
|
||||||
|
return path == prefix or path.startswith(prefix + "/")
|
||||||
|
|
||||||
|
|
||||||
def is_cors_preflight(method: str, headers) -> bool:
|
def is_cors_preflight(method: str, headers) -> bool:
|
||||||
@@ -47,7 +73,7 @@ def require_admin(request: Request):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
if os.getenv("AUTH_ENABLED", "true").lower() == "false":
|
if auth_disabled():
|
||||||
return
|
return
|
||||||
if not auth_mgr or not auth_mgr.is_configured:
|
if not auth_mgr or not auth_mgr.is_configured:
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ services:
|
|||||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||||
|
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
@@ -74,6 +75,11 @@ services:
|
|||||||
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
- 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:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
@@ -129,12 +135,17 @@ services:
|
|||||||
fi
|
fi
|
||||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||||
fi
|
fi
|
||||||
|
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||||
|
# not be what stops searxng from booting. It explains itself on stderr
|
||||||
|
# and we carry on, letting searxng report anything genuinely wrong.
|
||||||
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||||
environment:
|
environment:
|
||||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||||
|
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
@@ -73,6 +74,11 @@ services:
|
|||||||
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
- 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:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
@@ -132,12 +138,17 @@ services:
|
|||||||
fi
|
fi
|
||||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||||
fi
|
fi
|
||||||
|
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||||
|
# not be what stops searxng from booting. It explains itself on stderr
|
||||||
|
# and we carry on, letting searxng report anything genuinely wrong.
|
||||||
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||||
environment:
|
environment:
|
||||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ services:
|
|||||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||||
|
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
@@ -62,6 +63,11 @@ services:
|
|||||||
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
- 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:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
@@ -110,12 +116,17 @@ services:
|
|||||||
fi
|
fi
|
||||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||||
fi
|
fi
|
||||||
|
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||||
|
# not be what stops searxng from booting. It explains itself on stderr
|
||||||
|
# and we carry on, letting searxng report anything genuinely wrong.
|
||||||
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||||
environment:
|
environment:
|
||||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||||
|
|||||||
+148
@@ -497,6 +497,154 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
|
|||||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||||
|
|
||||||
|
#### Faster over the network: HTTP/2
|
||||||
|
|
||||||
|
The frontend is raw ES modules with no bundler, so a page load is a few hundred
|
||||||
|
small same-origin requests. Over HTTP/1.1 browsers typically allow only a small
|
||||||
|
number of concurrent connections per host (commonly around six), so many of
|
||||||
|
those requests are serialized across multiple round trips. On localhost that
|
||||||
|
costs almost nothing. Over a LAN, VPN, or remote link it can become a major
|
||||||
|
part of load time, especially as latency increases.
|
||||||
|
|
||||||
|
HTTP/2 multiplexes them onto one connection and the serialisation disappears.
|
||||||
|
Odysseus needs no changes for this — uvicorn keeps speaking HTTP/1.1 on
|
||||||
|
loopback and the proxy speaks HTTP/2 to the browser. Mainstream browsers
|
||||||
|
negotiate HTTP/2 for normal web pages over TLS; they do not use the cleartext
|
||||||
|
h2c mode here, so browser-facing HTTP/2 requires a certificate. The
|
||||||
|
`--ssl-certfile` route in *HTTPS + LAN/Tailscale exposure* above gives you
|
||||||
|
HTTPS but not HTTP/2 — uvicorn does not speak it.
|
||||||
|
|
||||||
|
**1. Install Caddy.** See the [install docs](https://caddyserver.com/docs/install)
|
||||||
|
for your platform; on macOS, `brew install caddy`.
|
||||||
|
|
||||||
|
**2. Write a `Caddyfile`.** Pick the block that matches how you reach the
|
||||||
|
machine. Replace `7000` if Odysseus listens elsewhere — the macOS start script
|
||||||
|
uses `7860`.
|
||||||
|
|
||||||
|
Public domain, Caddy obtains and renews the certificate itself:
|
||||||
|
|
||||||
|
```
|
||||||
|
odysseus.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:7000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tailscale, no public DNS needed — `tailscale cert` issues a browser-trusted
|
||||||
|
certificate for a tailnet name and writes `<domain>.crt` and `<domain>.key`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tailscale cert myhost.tailnet-name.ts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
myhost.tailnet-name.ts.net {
|
||||||
|
tls /path/to/myhost.tailnet-name.ts.net.crt /path/to/myhost.tailnet-name.ts.net.key
|
||||||
|
reverse_proxy 127.0.0.1:7000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
LAN with your own certificate — same shape, your own files:
|
||||||
|
|
||||||
|
```
|
||||||
|
odysseus.lan {
|
||||||
|
tls /path/to/cert.pem /path/to/key.pem
|
||||||
|
reverse_proxy 127.0.0.1:7000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Give `tls` absolute paths: a service starts in a working directory you did not
|
||||||
|
choose. If port 443 is already taken, append a port to the site address
|
||||||
|
(`odysseus.example.com:8443`) and use it in the URL. That alone does not free
|
||||||
|
port 80 — Caddy still binds it for the HTTP-to-HTTPS redirect, and fails to
|
||||||
|
start with `listen tcp :80: bind: address already in use` if something else
|
||||||
|
holds it. Turn the redirect off with a global block at the top of the file:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
auto_https disable_redirects
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run it in the foreground first:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caddy run --config ./Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Once that works, run it as a service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew services start caddy # macOS — reads $(brew --prefix)/etc/Caddyfile, not ./Caddyfile
|
||||||
|
sudo systemctl enable --now caddy # Linux, if your package installed the unit
|
||||||
|
```
|
||||||
|
|
||||||
|
Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
|
||||||
|
run the proxy as another container, or on the host pointing at the published
|
||||||
|
port.
|
||||||
|
|
||||||
|
**4. Point Odysseus at the new origin** in `.env`, then restart it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SECURE_COOKIES=true
|
||||||
|
# only if you use remote MCP servers with OAuth:
|
||||||
|
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Gmail OAuth needs nothing here when the proxy runs on the same host: the
|
||||||
|
redirect URI is built from the incoming request, and uvicorn rewrites the
|
||||||
|
scheme from `X-Forwarded-Proto` for proxies it trusts — by default only
|
||||||
|
`127.0.0.1`. A proxy in a separate container or on another machine is not
|
||||||
|
trusted, so pin the URI there:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOOGLE_OAUTH_REDIRECT_URI=https://odysseus.example.com/api/email/oauth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
(uvicorn's own `FORWARDED_ALLOW_IPS` widens that trust, but it has to be in the
|
||||||
|
environment uvicorn starts with — `.env` is read by the app afterwards, too
|
||||||
|
late for it to take effect.)
|
||||||
|
|
||||||
|
**5. Confirm HTTP/2 is really on:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_version}\n' https://odysseus.example.com/
|
||||||
|
# 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The status code is not the thing to check here — a logged-out request redirects
|
||||||
|
to the login page, so `curl -I` shows `HTTP/2 302`, and the `HTTP/2` prefix is
|
||||||
|
the part that matters. The browser reports the same in the Network panel's
|
||||||
|
Protocol column (`h2`); in Chrome and Firefox that column is hidden until you
|
||||||
|
enable it by right-clicking the column headers.
|
||||||
|
|
||||||
|
Three things bite when moving an existing install behind TLS:
|
||||||
|
|
||||||
|
- Set `SECURE_COOKIES=true` **at the same time** you stop serving plain HTTP,
|
||||||
|
not before. The flag is applied to every login regardless of the scheme the
|
||||||
|
request arrived on, so while an HTTP entrypoint is still reachable the
|
||||||
|
browser will reject the `Secure` cookie there and login will appear to loop.
|
||||||
|
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
|
||||||
|
Gmail redirect URI it cannot be derived from a request — it is registered
|
||||||
|
with each MCP authorization server up front — so set it to the external
|
||||||
|
origin if you use remote MCP servers over OAuth.
|
||||||
|
- Odysseus sends `Strict-Transport-Security` once it sees `X-Forwarded-Proto:
|
||||||
|
https`. HSTS applies to the whole hostname and ignores the port, so any other
|
||||||
|
plain-HTTP service on that same hostname becomes unreachable in browsers that
|
||||||
|
have visited Odysseus. Give Odysseus its own hostname, or strip the header at
|
||||||
|
the proxy (`header_down -Strict-Transport-Security` in Caddy).
|
||||||
|
|
||||||
|
Server-sent events are not buffered by this configuration, so chat streaming
|
||||||
|
arrives token by token; add `flush_interval -1` inside the `reverse_proxy`
|
||||||
|
block if you want that pinned explicitly. nginx needs `proxy_buffering off;`
|
||||||
|
for the same reason.
|
||||||
|
|
||||||
|
Changing the external origin also affects state scoped to it. Service workers
|
||||||
|
and their caches are origin-scoped, so moving to a different origin starts with
|
||||||
|
a cold load. Cookies follow their own domain/path/security rules rather than
|
||||||
|
being port-scoped: changing the hostname normally requires a new login, while
|
||||||
|
changing only the scheme or port does not by itself guarantee that existing
|
||||||
|
cookies disappear.
|
||||||
|
|
||||||
Common internal-only ports from the default docs/compose setup:
|
Common internal-only ports from the default docs/compose setup:
|
||||||
|
|
||||||
| Port | Service |
|
| Port | Service |
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ if (Test-Path $cudaBase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
|
# 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-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
|
||||||
Write-Host "Press Ctrl+C to stop."
|
Write-Host "Press Ctrl+C to stop."
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -1802,7 +1802,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
|||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_endpoint,
|
resolve_endpoint,
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
resolve_chat_fallback_candidates,
|
|
||||||
)
|
)
|
||||||
from src.llm_core import llm_call_async_with_fallback
|
from src.llm_core import llm_call_async_with_fallback
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1843,13 +1842,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
|||||||
utility_fallbacks = resolve_utility_fallback_candidates() or []
|
utility_fallbacks = resolve_utility_fallback_candidates() or []
|
||||||
for cand in utility_fallbacks:
|
for cand in utility_fallbacks:
|
||||||
_add(*cand)
|
_add(*cand)
|
||||||
try:
|
|
||||||
chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or []
|
|
||||||
except TypeError:
|
|
||||||
chat_fallbacks = resolve_chat_fallback_candidates() or []
|
|
||||||
for cand in chat_fallbacks:
|
|
||||||
_add(*cand)
|
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return {"error": "No LLM endpoint configured for AI reply"}
|
return {"error": "No LLM endpoint configured for AI reply"}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from core.auth import RESERVED_USERNAMES
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.task_scheduler import compute_next_run
|
from src.task_scheduler import compute_next_run
|
||||||
|
|
||||||
|
|
||||||
@@ -90,11 +90,12 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
|
|||||||
# check-in tasks seeded. Hitting any /assistant route under one of these
|
# check-in tasks seeded. Hitting any /assistant route under one of these
|
||||||
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
||||||
# owner, which then double-fired alongside the real user's check-ins.
|
# owner, which then double-fired alongside the real user's check-ins.
|
||||||
# RESERVED_USERNAMES covers the same set; the `not owner` guard handles "".
|
# REQUEST_SENTINEL_OWNERS covers request-only identities; Default/Local is a
|
||||||
|
# reserved login name but remains a valid storage owner.
|
||||||
|
|
||||||
async def _get_or_create(owner: str) -> CrewMember:
|
async def _get_or_create(owner: str) -> CrewMember:
|
||||||
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
||||||
if not owner or owner in RESERVED_USERNAMES:
|
if not owner or owner in REQUEST_SENTINEL_OWNERS:
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from src.settings import (
|
|||||||
load_features as _load_features,
|
load_features as _load_features,
|
||||||
save_features as _save_features,
|
save_features as _save_features,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
|
RETIRED_SETTING_KEYS,
|
||||||
|
without_retired_settings,
|
||||||
)
|
)
|
||||||
from src.integrations import (
|
from src.integrations import (
|
||||||
load_integrations,
|
load_integrations,
|
||||||
@@ -689,7 +691,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
a scrubbed copy with secret keys blanked. The frontend uses this
|
a scrubbed copy with secret keys blanked. The frontend uses this
|
||||||
for keybinds + TTS prefs, so it stays callable without admin."""
|
for keybinds + TTS prefs, so it stays callable without admin."""
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
settings = _load_settings()
|
settings = without_retired_settings(_load_settings())
|
||||||
if user and auth_manager.is_admin(user):
|
if user and auth_manager.is_admin(user):
|
||||||
return settings
|
return settings
|
||||||
return scrub_settings(settings)
|
return scrub_settings(settings)
|
||||||
@@ -709,6 +711,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||||
}
|
}
|
||||||
for key in DEFAULT_SETTINGS:
|
for key in DEFAULT_SETTINGS:
|
||||||
|
if key in RETIRED_SETTING_KEYS:
|
||||||
|
continue
|
||||||
if key not in body:
|
if key not in body:
|
||||||
continue
|
continue
|
||||||
val = body[key]
|
val = body[key]
|
||||||
@@ -721,7 +725,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
val = max(lo, min(val, hi))
|
val = max(lo, min(val, hi))
|
||||||
current[key] = val
|
current[key] = val
|
||||||
_save_settings(current)
|
_save_settings(current)
|
||||||
return current
|
return without_retired_settings(current)
|
||||||
|
|
||||||
# ---- Integrations CRUD ----
|
# ---- Integrations CRUD ----
|
||||||
|
|
||||||
|
|||||||
+47
-100
@@ -15,7 +15,7 @@ from core.database import Session as DBSession, ModelEndpoint
|
|||||||
from src.llm_core import normalize_model_id
|
from src.llm_core import normalize_model_id
|
||||||
from src.endpoint_resolver import normalize_base
|
from src.endpoint_resolver import normalize_base
|
||||||
from src.context_compactor import maybe_compact, trim_for_context
|
from src.context_compactor import maybe_compact, trim_for_context
|
||||||
from src.model_context import estimate_tokens
|
from src.model_context import estimate_tokens, get_context_length
|
||||||
from src.auth_helpers import effective_user
|
from src.auth_helpers import effective_user
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from src.attachment_refs import attachment_ref
|
from src.attachment_refs import attachment_ref
|
||||||
@@ -152,10 +152,38 @@ class ChatContext:
|
|||||||
# Uploads attached to this user turn, resolved and owner-checked for the
|
# Uploads attached to this user turn, resolved and owner-checked for the
|
||||||
# agent's private context. This is not emitted to the browser.
|
# agent's private context. This is not emitted to the browser.
|
||||||
uploaded_files: list = field(default_factory=list)
|
uploaded_files: list = field(default_factory=list)
|
||||||
|
# Route-neutral prompt before any model-window compaction/trimming. This is
|
||||||
|
# retained only when explicit foreground fallbacks are enabled so each
|
||||||
|
# concrete candidate can apply its own context budget independently.
|
||||||
|
route_messages: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────────────────── #
|
# ── Helpers ────────────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
|
||||||
|
if privs.get("block_all_models"):
|
||||||
|
return frozenset()
|
||||||
|
allowed_raw = privs.get("allowed_models")
|
||||||
|
allowed = allowed_raw if isinstance(allowed_raw, list) else []
|
||||||
|
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
|
||||||
|
return frozenset(model for model in allowed if isinstance(model, str)) if restricted else None
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
|
||||||
|
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = effective_user(request)
|
||||||
|
except Exception:
|
||||||
|
user = None
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
||||||
|
if not auth_manager:
|
||||||
|
return None
|
||||||
|
privs = auth_manager.get_privileges(user) or {}
|
||||||
|
return _allowed_models_from_privileges(privs)
|
||||||
|
|
||||||
def _enforce_chat_privileges(request, sess) -> None:
|
def _enforce_chat_privileges(request, sess) -> None:
|
||||||
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
|
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
|
||||||
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
|
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
|
||||||
@@ -185,10 +213,8 @@ def _enforce_chat_privileges(request, sess) -> None:
|
|||||||
if privs.get("block_all_models"):
|
if privs.get("block_all_models"):
|
||||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||||
|
|
||||||
allowed_raw = privs.get("allowed_models")
|
allowed_models = _allowed_models_from_privileges(privs)
|
||||||
allowed = allowed_raw if isinstance(allowed_raw, list) else []
|
if allowed_models is not None and sess.model and sess.model not in allowed_models:
|
||||||
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
|
|
||||||
if restricted and sess.model and sess.model not in allowed:
|
|
||||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||||
|
|
||||||
cap = int(privs.get("max_messages_per_day") or 0)
|
cap = int(privs.get("max_messages_per_day") or 0)
|
||||||
@@ -287,96 +313,6 @@ async def auto_name_session(session_manager, sess):
|
|||||||
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
|
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
|
||||||
|
|
||||||
|
|
||||||
def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|
||||||
"""Find an alternative working endpoint when the current one fails.
|
|
||||||
|
|
||||||
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
|
||||||
"""
|
|
||||||
import requests as _req
|
|
||||||
from src.endpoint_resolver import (
|
|
||||||
build_chat_url,
|
|
||||||
build_headers,
|
|
||||||
build_models_url,
|
|
||||||
normalize_base,
|
|
||||||
resolve_endpoint_runtime,
|
|
||||||
)
|
|
||||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
|
||||||
|
|
||||||
current_url = sess.endpoint_url or ""
|
|
||||||
owner = getattr(sess, "owner", None)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
q = db.query(ModelEndpoint).filter(
|
|
||||||
ModelEndpoint.is_enabled == True
|
|
||||||
)
|
|
||||||
if owner:
|
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
q = owner_filter(q, ModelEndpoint, owner)
|
|
||||||
endpoints = q.all()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
for ep in endpoints:
|
|
||||||
base = normalize_base(ep.base_url)
|
|
||||||
# Skip current endpoint
|
|
||||||
if current_url and base in current_url:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
ping_url = build_models_url(base)
|
|
||||||
headers = build_headers(api_key, base)
|
|
||||||
try:
|
|
||||||
if ping_url:
|
|
||||||
r = _req.get(ping_url, headers=headers, timeout=5)
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
|
||||||
if not models:
|
|
||||||
models = [
|
|
||||||
m.get("name") or m.get("model")
|
|
||||||
for m in (data.get("models") or [])
|
|
||||||
if m.get("name") or m.get("model")
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
models = json.loads(ep.cached_models or "[]")
|
|
||||||
if not models:
|
|
||||||
continue
|
|
||||||
# Found a working endpoint — update session
|
|
||||||
new_model = models[0]
|
|
||||||
chat_url = build_chat_url(base)
|
|
||||||
new_headers = build_headers(api_key, base)
|
|
||||||
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
|
|
||||||
|
|
||||||
sess.model = new_model
|
|
||||||
sess.endpoint_url = chat_url
|
|
||||||
sess.headers = new_headers
|
|
||||||
|
|
||||||
# Persist
|
|
||||||
_db = SessionLocal()
|
|
||||||
try:
|
|
||||||
_db.query(DBSession).filter(DBSession.id == session_id).update({
|
|
||||||
"model": new_model,
|
|
||||||
"endpoint_url": chat_url,
|
|
||||||
"headers": persisted_headers,
|
|
||||||
})
|
|
||||||
_db.commit()
|
|
||||||
finally:
|
|
||||||
_db.close()
|
|
||||||
|
|
||||||
logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})")
|
|
||||||
return {
|
|
||||||
"model": new_model,
|
|
||||||
"endpoint_url": chat_url,
|
|
||||||
"endpoint_name": ep.name,
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_preset(chat_handler, preset_id) -> PresetInfo:
|
def extract_preset(chat_handler, preset_id) -> PresetInfo:
|
||||||
"""Extract preset parameters via chat_handler."""
|
"""Extract preset parameters via chat_handler."""
|
||||||
temperature, max_tokens, system_prompt, char_name = (
|
temperature, max_tokens, system_prompt, char_name = (
|
||||||
@@ -687,6 +623,7 @@ async def build_chat_context(
|
|||||||
use_enhanced_message: bool = False,
|
use_enhanced_message: bool = False,
|
||||||
agent_mode: bool = False,
|
agent_mode: bool = False,
|
||||||
allow_tool_preprocessing: bool = True,
|
allow_tool_preprocessing: bool = True,
|
||||||
|
defer_context_shaping: bool = False,
|
||||||
) -> ChatContext:
|
) -> ChatContext:
|
||||||
"""Build the full context (preface + messages) for an LLM call.
|
"""Build the full context (preface + messages) for an LLM call.
|
||||||
|
|
||||||
@@ -830,13 +767,22 @@ async def build_chat_context(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to add current date/time context", exc_info=True)
|
logger.debug("Failed to add current date/time context", exc_info=True)
|
||||||
|
|
||||||
# Auto-compact
|
route_messages = list(messages)
|
||||||
messages, context_length, was_compacted = await maybe_compact(
|
# Explicit fallback routing must shape from the same route-neutral prompt
|
||||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
# for every candidate. Running selected-model compaction here would mutate
|
||||||
)
|
# session history before we know which route can answer and would make a
|
||||||
|
# later larger-context candidate unable to recover discarded history.
|
||||||
|
if defer_context_shaping:
|
||||||
|
context_length = get_context_length(sess.endpoint_url, sess.model)
|
||||||
|
was_compacted = False
|
||||||
|
else:
|
||||||
|
messages, context_length, was_compacted = await maybe_compact(
|
||||||
|
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||||
|
)
|
||||||
_before_trim_messages = len(messages)
|
_before_trim_messages = len(messages)
|
||||||
_before_trim_tokens = estimate_tokens(messages)
|
_before_trim_tokens = estimate_tokens(messages)
|
||||||
messages = trim_for_context(messages, context_length)
|
if not defer_context_shaping:
|
||||||
|
messages = trim_for_context(messages, context_length)
|
||||||
_after_trim_messages = len(messages)
|
_after_trim_messages = len(messages)
|
||||||
_after_trim_tokens = estimate_tokens(messages)
|
_after_trim_tokens = estimate_tokens(messages)
|
||||||
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
|
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
|
||||||
@@ -860,6 +806,7 @@ async def build_chat_context(
|
|||||||
context_tokens_after_trim=_after_trim_tokens,
|
context_tokens_after_trim=_after_trim_tokens,
|
||||||
auto_opened_docs=auto_opened_docs,
|
auto_opened_docs=auto_opened_docs,
|
||||||
uploaded_files=uploaded_files,
|
uploaded_files=uploaded_files,
|
||||||
|
route_messages=route_messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+664
-40
File diff suppressed because it is too large
Load Diff
+3
-10
@@ -5004,7 +5004,6 @@ def setup_email_routes():
|
|||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_endpoint,
|
resolve_endpoint,
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
resolve_chat_fallback_candidates,
|
|
||||||
)
|
)
|
||||||
from src.llm_core import llm_call_async_with_fallback
|
from src.llm_core import llm_call_async_with_fallback
|
||||||
|
|
||||||
@@ -5066,8 +5065,6 @@ def setup_email_routes():
|
|||||||
pass
|
pass
|
||||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||||
_add(*cand)
|
_add(*cand)
|
||||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
|
||||||
_add(*cand)
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return {"success": False, "error": "No LLM endpoint configured"}
|
return {"success": False, "error": "No LLM endpoint configured"}
|
||||||
|
|
||||||
@@ -5327,13 +5324,11 @@ def setup_email_routes():
|
|||||||
# Build a candidate chain so a stale session-stored API key
|
# Build a candidate chain so a stale session-stored API key
|
||||||
# (the most common cause of "authentication failed" here)
|
# (the most common cause of "authentication failed" here)
|
||||||
# doesn't kill AI Reply outright — fall through to the
|
# doesn't kill AI Reply outright — fall through to the
|
||||||
# user's Utility / Default endpoints and the active Utility
|
# user's Utility / Default endpoints and active Utility fallback
|
||||||
# fallback chain. The retired default-fallback hook stays empty.
|
# chain. Dedupe by url+model so we don't retry the same endpoint.
|
||||||
# Dedupe by url+model so we don't retry the same broken endpoint.
|
|
||||||
from src.llm_core import llm_call_async_with_fallback
|
from src.llm_core import llm_call_async_with_fallback
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
resolve_chat_fallback_candidates,
|
|
||||||
)
|
)
|
||||||
_seen = set()
|
_seen = set()
|
||||||
_candidates = []
|
_candidates = []
|
||||||
@@ -5358,11 +5353,9 @@ def setup_email_routes():
|
|||||||
_add(_d_url, _d_model, _d_headers)
|
_add(_d_url, _d_model, _d_headers)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Active Utility fallbacks, then the retired default hook.
|
# Active Utility fallbacks last.
|
||||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||||
_add(*cand)
|
_add(*cand)
|
||||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
|
||||||
_add(*cand)
|
|
||||||
_messages = [
|
_messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_msg},
|
{"role": "user", "content": user_msg},
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
return RedirectResponse(auth_url)
|
return RedirectResponse(auth_url)
|
||||||
else:
|
else:
|
||||||
# Remote device — show paste-back page
|
# 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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -612,15 +612,13 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
def _oauth_authorize_page(
|
def _oauth_authorize_page(
|
||||||
auth_url: str,
|
auth_url: str,
|
||||||
server_id: str,
|
server_id: str,
|
||||||
host: str,
|
redirect_uri: str,
|
||||||
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Page with Google sign-in link and URL paste-back form for remote access."""
|
"""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
|
# Escape values interpolated into the page: `server_id` comes from the OAuth
|
||||||
# Host header and `server_id` from the OAuth state — neither is trusted.
|
# state and is not trusted.
|
||||||
auth_url = html.escape(auth_url, quote=True)
|
auth_url = html.escape(auth_url, quote=True)
|
||||||
server_id = html.escape(server_id, quote=True)
|
server_id = html.escape(server_id, quote=True)
|
||||||
host = html.escape(host, quote=True)
|
|
||||||
redirect_uri = html.escape(redirect_uri, quote=True)
|
redirect_uri = html.escape(redirect_uri, quote=True)
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html><head>
|
<html><head>
|
||||||
@@ -664,7 +662,15 @@ def _oauth_authorize_page(
|
|||||||
</div>
|
</div>
|
||||||
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
|
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
|
||||||
<div class="divider"></div>
|
<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>
|
<p>Paste the URL from your browser after signing in:</p>
|
||||||
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
|
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
|
||||||
<br><button type="submit">Connect</button>
|
<br><button type="submit">Connect</button>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ _ENDPOINT_SETTING_FIELDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ENDPOINT_FALLBACK_FIELDS = {
|
_ENDPOINT_FALLBACK_FIELDS = {
|
||||||
|
"foreground_model_fallbacks": "Foreground Model Fallbacks",
|
||||||
"utility_model_fallbacks": "Utility Model Fallbacks",
|
"utility_model_fallbacks": "Utility Model Fallbacks",
|
||||||
"vision_model_fallbacks": "Vision Model Fallbacks",
|
"vision_model_fallbacks": "Vision Model Fallbacks",
|
||||||
}
|
}
|
||||||
@@ -180,7 +181,12 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
|||||||
if not isinstance(all_prefs, dict):
|
if not isinstance(all_prefs, dict):
|
||||||
return 0
|
return 0
|
||||||
users = all_prefs.get("_users")
|
users = all_prefs.get("_users")
|
||||||
pref_sets = users.values() if isinstance(users, dict) else [all_prefs]
|
# A mixed store can contain auth-disabled foreground policy at the root
|
||||||
|
# alongside named-owner preferences. Both are active namespaces; legacy
|
||||||
|
# `default_model_fallbacks` remains untouched by the field allowlist.
|
||||||
|
pref_sets = [all_prefs]
|
||||||
|
if isinstance(users, dict):
|
||||||
|
pref_sets.extend(users.values())
|
||||||
cleared_users = 0
|
cleared_users = 0
|
||||||
for prefs in pref_sets:
|
for prefs in pref_sets:
|
||||||
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
|
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
|
||||||
|
|||||||
+162
-91
@@ -1,11 +1,13 @@
|
|||||||
# routes/personal_routes.py
|
# routes/personal_routes.py
|
||||||
"""Routes for personal documents management."""
|
"""Routes for personal documents management."""
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from src.request_models import DirectoryRequest
|
from src.request_models import DirectoryRequest
|
||||||
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
||||||
from src.rag_singleton import get_rag_manager
|
from src.rag_singleton import get_rag_manager
|
||||||
@@ -18,7 +20,6 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
|
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
|
||||||
"""Return the per-owner upload directory used for direct RAG uploads."""
|
"""Return the per-owner upload directory used for direct RAG uploads."""
|
||||||
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
||||||
@@ -141,6 +142,22 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
"""
|
"""
|
||||||
router = APIRouter(prefix="/api/personal")
|
router = APIRouter(prefix="/api/personal")
|
||||||
|
|
||||||
|
# Serializes directory index jobs across requests. Indexing runs in the
|
||||||
|
# threadpool (#5558), so concurrent requests would otherwise run in parallel
|
||||||
|
# and race PersonalDocsManager's unsynchronized list mutations and file
|
||||||
|
# writes; before the threadpool move they serialized on the blocked event
|
||||||
|
# loop, so one-at-a-time is behavior parity.
|
||||||
|
#
|
||||||
|
# An asyncio.Lock acquired in the async handler BEFORE offloading: a waiting
|
||||||
|
# request parks on the event loop instead of pinning a threadpool worker (an
|
||||||
|
# earlier threading.Lock taken INSIDE the worker meant queued jobs held pool
|
||||||
|
# tokens while blocked, starving every other run_in_threadpool caller).
|
||||||
|
# add/remove/reload all take this lock, so their mutations never interleave.
|
||||||
|
# Per-router (not module-global) so each app binds it to its own event loop.
|
||||||
|
# Scope is the single process: multi-worker deployments would need a shared
|
||||||
|
# lock (out of scope for #5558).
|
||||||
|
_index_job_lock = asyncio.Lock()
|
||||||
|
|
||||||
def _rag():
|
def _rag():
|
||||||
"""Get the current RAG manager, retrying init if needed."""
|
"""Get the current RAG manager, retrying init if needed."""
|
||||||
return get_rag_manager()
|
return get_rag_manager()
|
||||||
@@ -172,8 +189,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
return {"files": files, "directories": directories}
|
return {"files": files, "directories": directories}
|
||||||
|
|
||||||
@router.post("/reload")
|
@router.post("/reload")
|
||||||
def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
async def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||||
personal_docs_manager.refresh_index()
|
# refresh_index() re-extracts text across every tracked directory —
|
||||||
|
# blocking work. Take the shared job lock (so it cannot race an add /
|
||||||
|
# remove) and run it off the event loop.
|
||||||
|
async with _index_job_lock:
|
||||||
|
await run_in_threadpool(personal_docs_manager.refresh_index)
|
||||||
return {"ok": True, "count": len(personal_docs_manager.index)}
|
return {"ok": True, "count": len(personal_docs_manager.index)}
|
||||||
|
|
||||||
@router.post("/add_directory")
|
@router.post("/add_directory")
|
||||||
@@ -207,12 +228,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
# Use the RAGManager to index the directory
|
# Use the RAGManager to index the directory
|
||||||
rag = _rag()
|
rag = _rag()
|
||||||
if rag:
|
if rag:
|
||||||
result = rag.index_personal_documents(directory, owner=owner)
|
def _index_directory():
|
||||||
|
result = rag.index_personal_documents(directory, owner=owner)
|
||||||
|
if result["success"]:
|
||||||
|
# Also update the personal_docs_manager to track this
|
||||||
|
# directory. Kept inside the offloaded call: it triggers
|
||||||
|
# refresh_index(), which re-extracts text across tracked
|
||||||
|
# directories.
|
||||||
|
personal_docs_manager.add_directory(directory, index=False)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Indexing walks, embeds, and stores the whole tree — minutes
|
||||||
|
# on a real directory. The handler is async, so calling it
|
||||||
|
# inline runs it on the event loop and every other request
|
||||||
|
# queues behind it until it finishes (#5558). Serialize on the
|
||||||
|
# async job lock BEFORE offloading so a queued request parks on
|
||||||
|
# the loop instead of pinning a threadpool worker.
|
||||||
|
async with _index_job_lock:
|
||||||
|
result = await run_in_threadpool(_index_directory)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
# Also update the personal_docs_manager to track this directory
|
|
||||||
personal_docs_manager.add_directory(directory, index=False)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
|
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
|
||||||
@@ -251,17 +286,25 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
|
|
||||||
logger.info(f"Removing directory from RAG: {directory}")
|
logger.info(f"Removing directory from RAG: {directory}")
|
||||||
|
|
||||||
# Always remove from personal_docs_manager tracking
|
|
||||||
if hasattr(personal_docs_manager, 'remove_directory'):
|
|
||||||
personal_docs_manager.remove_directory(directory)
|
|
||||||
|
|
||||||
# Remove from RAG vector store (best-effort)
|
|
||||||
rag = _rag()
|
rag = _rag()
|
||||||
if rag:
|
|
||||||
try:
|
def _remove_directory():
|
||||||
rag.remove_directory(directory)
|
# Always remove from personal_docs_manager tracking. This
|
||||||
except Exception as e:
|
# mutates the same unsynchronized list/index an add job touches
|
||||||
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
# and re-extracts text (refresh_index), so it is blocking work.
|
||||||
|
if hasattr(personal_docs_manager, 'remove_directory'):
|
||||||
|
personal_docs_manager.remove_directory(directory)
|
||||||
|
# Remove from RAG vector store (best-effort).
|
||||||
|
if rag:
|
||||||
|
try:
|
||||||
|
rag.remove_directory(directory)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
||||||
|
|
||||||
|
# Same job lock as add/reload so remove cannot interleave with an
|
||||||
|
# in-flight add; offloaded off the event loop.
|
||||||
|
async with _index_job_lock:
|
||||||
|
await run_in_threadpool(_remove_directory)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -289,54 +332,73 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
total_failed = 0
|
total_failed = 0
|
||||||
uploaded_files = []
|
uploaded_files = []
|
||||||
|
|
||||||
for upload in files:
|
# Chunking, embedding and the tracking update are blocking work over the
|
||||||
try:
|
# same vector/tracking state add_directory mutates (#5634). Take the
|
||||||
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
|
# shared job lock BEFORE offloading so a queued request parks on the loop
|
||||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
# instead of pinning a threadpool worker, matching add_directory.
|
||||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
# Read and process one capped payload at a time so a multi-file request
|
||||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
# cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
|
||||||
total_failed += 1
|
async with _index_job_lock:
|
||||||
continue
|
for upload in files:
|
||||||
with open(file_path, "wb") as f:
|
try:
|
||||||
f.write(content_bytes)
|
file_path, stored_name, safe_name = _unique_personal_upload_path(
|
||||||
|
upload_dir, upload.filename
|
||||||
ext = os.path.splitext(safe_name)[1].lower()
|
)
|
||||||
if ext == ".pdf":
|
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||||
from src.personal_docs import extract_pdf_text
|
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||||
text = extract_pdf_text(file_path)
|
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||||
else:
|
|
||||||
text = content_bytes.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
if not text or not text.strip():
|
|
||||||
total_failed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Chunk and index
|
|
||||||
chunks = rag._split_into_chunks(text, chunk_size=500)
|
|
||||||
for i, chunk in enumerate(chunks):
|
|
||||||
metadata = {
|
|
||||||
"source": file_path,
|
|
||||||
"filename": safe_name,
|
|
||||||
"stored_filename": stored_name,
|
|
||||||
"directory": upload_dir,
|
|
||||||
"type": ext,
|
|
||||||
"chunk_id": i,
|
|
||||||
}
|
|
||||||
if user:
|
|
||||||
metadata["owner"] = user
|
|
||||||
if rag.add_document(chunk, metadata):
|
|
||||||
total_indexed += 1
|
|
||||||
else:
|
|
||||||
total_failed += 1
|
total_failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
uploaded_files.append(safe_name)
|
def _index_upload():
|
||||||
except Exception as e:
|
with open(file_path, "wb") as f:
|
||||||
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
f.write(content_bytes)
|
||||||
total_failed += 1
|
|
||||||
|
|
||||||
# Track uploads directory
|
ext = os.path.splitext(safe_name)[1].lower()
|
||||||
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
if ext == ".pdf":
|
||||||
personal_docs_manager.add_directory(upload_dir, index=False)
|
from src.personal_docs import extract_pdf_text
|
||||||
|
text = extract_pdf_text(file_path)
|
||||||
|
else:
|
||||||
|
text = content_bytes.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
return 0, 1, None
|
||||||
|
|
||||||
|
indexed = 0
|
||||||
|
failed = 0
|
||||||
|
chunks = rag._split_into_chunks(text, chunk_size=500)
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
metadata = {
|
||||||
|
"source": file_path,
|
||||||
|
"filename": safe_name,
|
||||||
|
"stored_filename": stored_name,
|
||||||
|
"directory": upload_dir,
|
||||||
|
"type": ext,
|
||||||
|
"chunk_id": i,
|
||||||
|
}
|
||||||
|
if user:
|
||||||
|
metadata["owner"] = user
|
||||||
|
if rag.add_document(chunk, metadata):
|
||||||
|
indexed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
return indexed, failed, safe_name
|
||||||
|
|
||||||
|
indexed, failed, uploaded_name = await run_in_threadpool(_index_upload)
|
||||||
|
total_indexed += indexed
|
||||||
|
total_failed += failed
|
||||||
|
if uploaded_name:
|
||||||
|
uploaded_files.append(uploaded_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
||||||
|
total_failed += 1
|
||||||
|
|
||||||
|
# Same transition, same lock: the tracking update must not land
|
||||||
|
# while another job is mid-write over the same state.
|
||||||
|
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
||||||
|
await run_in_threadpool(
|
||||||
|
personal_docs_manager.add_directory, upload_dir, index=False
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -349,38 +411,47 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||||
"""Delete a specific file from RAG index and optionally from disk."""
|
"""Delete a specific file from RAG index and optionally from disk."""
|
||||||
try:
|
try:
|
||||||
# Remove chunks from RAG vector store (best-effort)
|
def _delete_file():
|
||||||
removed = 0
|
# Remove chunks from RAG vector store (best-effort)
|
||||||
rag = _rag()
|
removed = 0
|
||||||
if rag:
|
rag = _rag()
|
||||||
try:
|
if rag:
|
||||||
removed = rag.delete_by_source(filepath)
|
try:
|
||||||
except Exception as e:
|
removed = rag.delete_by_source(filepath)
|
||||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
except Exception as e:
|
||||||
|
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||||
|
|
||||||
# Delete file from disk if it's in the caller's own uploads dir.
|
# Delete file from disk if it's in the caller's own uploads dir.
|
||||||
# Scope to the per-owner subdir, not the shared uploads root, so one
|
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||||
# admin can't delete another user's personal files by path.
|
# admin can't delete another user's personal files by path.
|
||||||
deleted_from_disk = False
|
deleted_from_disk = False
|
||||||
try:
|
|
||||||
abs_target = os.path.realpath(filepath)
|
|
||||||
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
|
||||||
in_uploads = (
|
|
||||||
abs_target == base_abs
|
|
||||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# commonpath raises on mixed drives / non-comparable paths
|
|
||||||
in_uploads = False
|
|
||||||
if in_uploads and abs_target != base_abs:
|
|
||||||
try:
|
try:
|
||||||
os.remove(abs_target)
|
abs_target = os.path.realpath(filepath)
|
||||||
deleted_from_disk = True
|
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||||
except FileNotFoundError:
|
in_uploads = (
|
||||||
pass # already gone — race with another request or cleanup
|
abs_target == base_abs
|
||||||
|
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# commonpath raises on mixed drives / non-comparable paths
|
||||||
|
in_uploads = False
|
||||||
|
if in_uploads and abs_target != base_abs:
|
||||||
|
try:
|
||||||
|
os.remove(abs_target)
|
||||||
|
deleted_from_disk = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # already gone — race with another request or cleanup
|
||||||
|
|
||||||
# Exclude the file from the listing (persists across restarts)
|
# Exclude the file from the listing (persists across restarts)
|
||||||
personal_docs_manager.exclude_file(filepath)
|
personal_docs_manager.exclude_file(filepath)
|
||||||
|
return removed, deleted_from_disk
|
||||||
|
|
||||||
|
# Vector removal, the disk unlink and the exclusion write are one
|
||||||
|
# transition over the same state add_directory mutates (#5634), and
|
||||||
|
# all three block. Take the shared job lock BEFORE offloading, as
|
||||||
|
# add_directory does.
|
||||||
|
async with _index_job_lock:
|
||||||
|
removed, deleted_from_disk = await run_in_threadpool(_delete_file)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
+51
-11
@@ -7,6 +7,10 @@ from src.auth_helpers import get_current_user
|
|||||||
from src.constants import USER_PREFS_FILE
|
from src.constants import USER_PREFS_FILE
|
||||||
|
|
||||||
PREFS_FILE = USER_PREFS_FILE
|
PREFS_FILE = USER_PREFS_FILE
|
||||||
|
_FOREGROUND_POLICY_KEYS = (
|
||||||
|
"foreground_fallback_enabled",
|
||||||
|
"foreground_model_fallbacks",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
def _load():
|
||||||
@@ -26,14 +30,27 @@ def _save(prefs):
|
|||||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||||
"""Load preferences for a specific user."""
|
"""Load preferences for a specific user."""
|
||||||
all_prefs = _load()
|
all_prefs = _load()
|
||||||
if "_users" in all_prefs:
|
users = all_prefs.get("_users")
|
||||||
|
if isinstance(users, dict):
|
||||||
if user is None:
|
if user is None:
|
||||||
# Auth disabled — return first user's prefs for backward compat
|
# Auth disabled — return first user's prefs for backward compat
|
||||||
users = all_prefs["_users"]
|
prefs = dict(next(iter(users.values()), {}))
|
||||||
return dict(next(iter(users.values()), {}))
|
# Foreground fallback consent is never borrowed from a named
|
||||||
return dict(all_prefs["_users"].get(user, {}))
|
# owner. Auth-disabled operation has a separate flat/root opt-in
|
||||||
# Legacy flat format — return as-is
|
# that remains inert when authentication is enabled again.
|
||||||
return dict(all_prefs)
|
for key in _FOREGROUND_POLICY_KEYS:
|
||||||
|
prefs.pop(key, None)
|
||||||
|
if key in all_prefs:
|
||||||
|
prefs[key] = all_prefs[key]
|
||||||
|
return prefs
|
||||||
|
prefs = users.get(user, {})
|
||||||
|
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||||
|
# A legacy flat store belongs only to auth-disabled single-user mode.
|
||||||
|
# Copying it into the first named user's new `_users` record during an
|
||||||
|
# auth transition would silently transfer another user's preferences and,
|
||||||
|
# critically, foreground fallback consent. Named owners therefore start
|
||||||
|
# with an empty record and must write their own preferences explicitly.
|
||||||
|
return dict(all_prefs) if user is None else {}
|
||||||
|
|
||||||
|
|
||||||
def _save_for_user(user: Optional[str], prefs: dict):
|
def _save_for_user(user: Optional[str], prefs: dict):
|
||||||
@@ -45,17 +62,40 @@ def _save_for_user(user: Optional[str], prefs: dict):
|
|||||||
# `prefs` flat would overwrite the whole `_users` map and destroy every
|
# `prefs` flat would overwrite the whole `_users` map and destroy every
|
||||||
# other user's preferences. Instead write back into the same (first)
|
# other user's preferences. Instead write back into the same (first)
|
||||||
# slot _load_for_user(None) reads from, preserving the others.
|
# slot _load_for_user(None) reads from, preserving the others.
|
||||||
if "_users" in all_prefs:
|
users = all_prefs.get("_users")
|
||||||
users = all_prefs["_users"]
|
if isinstance(users, dict):
|
||||||
first_key = next(iter(users), None)
|
first_key = next(iter(users), None)
|
||||||
if first_key is not None:
|
if first_key is not None:
|
||||||
users[first_key] = prefs
|
existing_named = users.get(first_key)
|
||||||
|
existing_named = (
|
||||||
|
dict(existing_named)
|
||||||
|
if isinstance(existing_named, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
named_foreground = {
|
||||||
|
key: existing_named[key]
|
||||||
|
for key in _FOREGROUND_POLICY_KEYS
|
||||||
|
if key in existing_named
|
||||||
|
}
|
||||||
|
users[first_key] = {
|
||||||
|
key: value
|
||||||
|
for key, value in prefs.items()
|
||||||
|
if key not in _FOREGROUND_POLICY_KEYS
|
||||||
|
}
|
||||||
|
users[first_key].update(named_foreground)
|
||||||
|
for key in _FOREGROUND_POLICY_KEYS:
|
||||||
|
if key in prefs:
|
||||||
|
all_prefs[key] = prefs[key]
|
||||||
_save(all_prefs)
|
_save(all_prefs)
|
||||||
return
|
return
|
||||||
_save(prefs)
|
_save(prefs)
|
||||||
return
|
return
|
||||||
if "_users" not in all_prefs:
|
if not isinstance(all_prefs.get("_users"), dict):
|
||||||
all_prefs = {"_users": {}}
|
# Preserve the flat single-user object as inert legacy data while
|
||||||
|
# creating the first named-owner namespace. In particular, historical
|
||||||
|
# fallback values must not be deleted or copied into the new owner.
|
||||||
|
all_prefs = dict(all_prefs)
|
||||||
|
all_prefs["_users"] = {}
|
||||||
all_prefs["_users"][user] = prefs
|
all_prefs["_users"][user] = prefs
|
||||||
_save(all_prefs)
|
_save(all_prefs)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from pydantic import BaseModel, Field
|
|||||||
from core.middleware import INTERNAL_TOOL_USER
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
from core.auth import RESERVED_USERNAMES
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.constants import DEEP_RESEARCH_DIR
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
|
||||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||||
@@ -496,7 +496,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
user = require_privilege(request, "can_use_research")
|
user = require_privilege(request, "can_use_research")
|
||||||
if user == INTERNAL_TOOL_USER:
|
if user == INTERNAL_TOOL_USER:
|
||||||
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
||||||
if tool_owner and tool_owner not in RESERVED_USERNAMES:
|
if tool_owner and tool_owner not in REQUEST_SENTINEL_OWNERS:
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+251
-17
@@ -18,6 +18,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from services.memory.skills import SkillsManager
|
from services.memory.skills import SkillsManager
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
from src.prompt_security import untrusted_context_message
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -107,6 +108,23 @@ def _skill_test_task(skill: dict) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_test_messages(md: str, task: str) -> list[dict]:
|
||||||
|
"""Keep user-editable skill text out of the trusted system role."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You are TESTING a skill. Follow the supplied reusable procedure "
|
||||||
|
"to complete the user's task for real, using available tools step "
|
||||||
|
"by step. If the skill is wrong, unclear, or references tools that "
|
||||||
|
"do not exist, do your best; the problems will be reviewed afterward."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
untrusted_context_message("skill under test", md),
|
||||||
|
{"role": "user", "content": task},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||||
url: str, model: str, headers: Optional[dict]) -> dict:
|
url: str, model: str, headers: Optional[dict]) -> dict:
|
||||||
"""LLM-as-judge: grade a skill test run from its transcript. Advisory only.
|
"""LLM-as-judge: grade a skill test run from its transcript. Advisory only.
|
||||||
@@ -411,7 +429,21 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
|||||||
_skill_test_jobs: dict = {}
|
_skill_test_jobs: dict = {}
|
||||||
|
|
||||||
|
|
||||||
async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, skills_manager=None):
|
async def _run_skill_test_job(
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
md,
|
||||||
|
task,
|
||||||
|
url,
|
||||||
|
model,
|
||||||
|
headers,
|
||||||
|
owner,
|
||||||
|
skills_manager=None,
|
||||||
|
*,
|
||||||
|
messages=None,
|
||||||
|
transcript=None,
|
||||||
|
exact_approval=None,
|
||||||
|
):
|
||||||
"""Background coroutine: run the skill in an agent loop, capture a condensed
|
"""Background coroutine: run the skill in an agent loop, capture a condensed
|
||||||
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
|
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
|
||||||
import json as _json
|
import json as _json
|
||||||
@@ -421,7 +453,7 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
|
|||||||
if job is None:
|
if job is None:
|
||||||
return
|
return
|
||||||
log = job["log"]
|
log = job["log"]
|
||||||
transcript = []
|
transcript = transcript if isinstance(transcript, list) else []
|
||||||
say_buf = []
|
say_buf = []
|
||||||
|
|
||||||
def _flush_say():
|
def _flush_say():
|
||||||
@@ -429,18 +461,12 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
|
|||||||
log.append({"type": "say", "text": "".join(say_buf)})
|
log.append({"type": "say", "text": "".join(say_buf)})
|
||||||
say_buf.clear()
|
say_buf.clear()
|
||||||
|
|
||||||
messages = [
|
messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
|
||||||
{"role": "system", "content":
|
|
||||||
"You are TESTING a skill. Below is a reusable skill (a procedure). Follow it "
|
|
||||||
"to complete the user's task for real, using your available tools, step by "
|
|
||||||
"step. If the skill is wrong, unclear, or references tools that don't exist, "
|
|
||||||
"do your best — the problems will be reviewed afterward.\n\n=== SKILL ===\n" + md},
|
|
||||||
{"role": "user", "content": task},
|
|
||||||
]
|
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_agent_loop(
|
async for chunk in stream_agent_loop(
|
||||||
url, model, messages, headers=headers,
|
url, model, messages, headers=headers,
|
||||||
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
||||||
|
exact_approval=exact_approval,
|
||||||
):
|
):
|
||||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||||
continue
|
continue
|
||||||
@@ -458,8 +484,25 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
|
|||||||
elif d.get("type") == "tool_output":
|
elif d.get("type") == "tool_output":
|
||||||
_flush_say()
|
_flush_say()
|
||||||
out = str(d.get("output") or "")[:600]
|
out = str(d.get("output") or "")[:600]
|
||||||
log.append({"type": "tool_output", "output": out})
|
tool_log = {"type": "tool_output", "output": out}
|
||||||
|
approval = d.get("ask_user")
|
||||||
|
if isinstance(approval, dict):
|
||||||
|
tool_log["ask_user"] = approval
|
||||||
|
log.append(tool_log)
|
||||||
transcript.append(f"[output] {out}\n")
|
transcript.append(f"[output] {out}\n")
|
||||||
|
if (
|
||||||
|
isinstance(approval, dict)
|
||||||
|
and approval.get("kind") == "tool_approval"
|
||||||
|
and approval.get("approval_id")
|
||||||
|
):
|
||||||
|
# Manual skill tests have their own polling UI instead of a
|
||||||
|
# chat session. Pause the run and retain only server-side
|
||||||
|
# continuation state until the same owner approves/denies
|
||||||
|
# this exact sealed action.
|
||||||
|
job["status"] = "awaiting_approval"
|
||||||
|
job["approval"] = approval
|
||||||
|
job["_transcript"] = transcript
|
||||||
|
return
|
||||||
elif d.get("type") == "agent_step":
|
elif d.get("type") == "agent_step":
|
||||||
_flush_say()
|
_flush_say()
|
||||||
log.append({"type": "agent_step", "round": d.get("round")})
|
log.append({"type": "agent_step", "round": d.get("round")})
|
||||||
@@ -471,6 +514,9 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
|
|||||||
_flush_say()
|
_flush_say()
|
||||||
log.append({"type": "error", "error": str(e)})
|
log.append({"type": "error", "error": str(e)})
|
||||||
|
|
||||||
|
job.pop("approval", None)
|
||||||
|
job.pop("_transcript", None)
|
||||||
|
job.pop("_run", None)
|
||||||
log.append({"type": "evaluating"})
|
log.append({"type": "evaluating"})
|
||||||
try:
|
try:
|
||||||
job["verdict"] = await _eval_skill_run(md, task, "".join(transcript), url, model, headers)
|
job["verdict"] = await _eval_skill_run(md, task, "".join(transcript), url, model, headers)
|
||||||
@@ -694,12 +740,8 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
|
|||||||
import json as _json
|
import json as _json
|
||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
transcript = []
|
transcript = []
|
||||||
messages = [
|
approval_required = None
|
||||||
{"role": "system", "content":
|
messages = _skill_test_messages(md, task)
|
||||||
"You are TESTING a skill. Follow this skill's procedure to complete the task "
|
|
||||||
"for real, using your tools, step by step.\n\n=== SKILL ===\n" + md},
|
|
||||||
{"role": "user", "content": task},
|
|
||||||
]
|
|
||||||
try:
|
try:
|
||||||
# max_tokens explicitly set: passing 0 lets some upstreams (Ollama,
|
# max_tokens explicitly set: passing 0 lets some upstreams (Ollama,
|
||||||
# OpenAI-compat) generate an empty completion, which manifested as
|
# OpenAI-compat) generate an empty completion, which manifested as
|
||||||
@@ -719,11 +761,44 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
|
|||||||
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
||||||
elif d.get("type") == "tool_output":
|
elif d.get("type") == "tool_output":
|
||||||
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
||||||
|
approval = d.get("ask_user")
|
||||||
|
if (
|
||||||
|
isinstance(approval, dict)
|
||||||
|
and approval.get("kind") == "tool_approval"
|
||||||
|
):
|
||||||
|
approval_required = approval
|
||||||
|
break
|
||||||
elif d.get("type") == "agent_step":
|
elif d.get("type") == "agent_step":
|
||||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
transcript.append(f"\n[run error] {e}\n")
|
transcript.append(f"\n[run error] {e}\n")
|
||||||
text = "".join(transcript)
|
text = "".join(transcript)
|
||||||
|
if approval_required is not None:
|
||||||
|
# Unattended audits have no authority to approve and no UI that could
|
||||||
|
# resume this record. Destructively deny it now instead of leaving a
|
||||||
|
# reusable opaque grant pending until TTL/cap eviction.
|
||||||
|
try:
|
||||||
|
from src.tool_approvals import tool_approval_store
|
||||||
|
tool_approval_store.consume(
|
||||||
|
approval_required.get("approval_id"),
|
||||||
|
decision="deny",
|
||||||
|
owner=owner,
|
||||||
|
session_id=None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not retire unattended skill approval", exc_info=True)
|
||||||
|
return text, {
|
||||||
|
"verdict": "inconclusive",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"summary": (
|
||||||
|
"This automated audit reached an exact action that requires "
|
||||||
|
"a human approval; no action was executed."
|
||||||
|
),
|
||||||
|
"issues": [
|
||||||
|
"Run this skill's manual test and review the sealed action."
|
||||||
|
],
|
||||||
|
"approval_required": True,
|
||||||
|
}
|
||||||
verdict = await _eval_skill_run(md, task, text, url, model, headers)
|
verdict = await _eval_skill_run(md, task, text, url, model, headers)
|
||||||
return text, verdict
|
return text, verdict
|
||||||
|
|
||||||
@@ -863,6 +938,26 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|||||||
transcript, verdict = await _run_skill_test_once(md, task, url, model, headers, owner)
|
transcript, verdict = await _run_skill_test_once(md, task, url, model, headers, owner)
|
||||||
v = verdict.get("verdict")
|
v = verdict.get("verdict")
|
||||||
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
|
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
|
||||||
|
if verdict.get("approval_required"):
|
||||||
|
# An unattended audit is not authority for an action influenced by the
|
||||||
|
# skill under test. Preserve the skill's current publication/confidence
|
||||||
|
# state and route the exact action to the manual test UI instead of
|
||||||
|
# letting a safety pause demote, rewrite, or auto-publish the skill.
|
||||||
|
skills_manager.set_audit(
|
||||||
|
name,
|
||||||
|
"inconclusive",
|
||||||
|
by_teacher=False,
|
||||||
|
worker_model=model,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
status = skill.get("status") or "draft"
|
||||||
|
log(f"{name}: {status} unchanged — exact action needs manual approval")
|
||||||
|
return {
|
||||||
|
"skill": name,
|
||||||
|
"result": "approval_required",
|
||||||
|
"verdict": verdict,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
if v == "pass":
|
if v == "pass":
|
||||||
# Procedure works. If the reviewer still flagged metadata (tags/category/
|
# Procedure works. If the reviewer still flagged metadata (tags/category/
|
||||||
# when_to_use/description), do ONE fixer pass to correct the frontmatter
|
# when_to_use/description), do ONE fixer pass to correct the frontmatter
|
||||||
@@ -1431,6 +1526,19 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
logger.warning(f"Skill-test model resolve failed: {_e}")
|
logger.warning(f"Skill-test model resolve failed: {_e}")
|
||||||
|
|
||||||
key = (user or "", name)
|
key = (user or "", name)
|
||||||
|
previous_job = _skill_test_jobs.get(key) or {}
|
||||||
|
previous_approval = previous_job.get("approval") or {}
|
||||||
|
if previous_approval.get("approval_id"):
|
||||||
|
try:
|
||||||
|
from src.tool_approvals import tool_approval_store
|
||||||
|
tool_approval_store.consume(
|
||||||
|
previous_approval["approval_id"],
|
||||||
|
decision="deny",
|
||||||
|
owner=user,
|
||||||
|
session_id=None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not retire replaced skill approval", exc_info=True)
|
||||||
_skill_test_jobs[key] = {
|
_skill_test_jobs[key] = {
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"task": task,
|
"task": task,
|
||||||
@@ -1439,10 +1547,135 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
"started": _time.time(),
|
"started": _time.time(),
|
||||||
"log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}],
|
"log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}],
|
||||||
"verdict": None,
|
"verdict": None,
|
||||||
|
"_run": {
|
||||||
|
"md": md,
|
||||||
|
"url": url,
|
||||||
|
"model": model,
|
||||||
|
"headers": headers,
|
||||||
|
"owner": user,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
|
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
|
||||||
return {"ok": True, "status": "running", "skill": name, "model": model}
|
return {"ok": True, "status": "running", "skill": name, "model": model}
|
||||||
|
|
||||||
|
@router.post("/{skill_id}/test-approval")
|
||||||
|
async def approve_skill_test_action(request: Request, skill_id: str):
|
||||||
|
"""Resume a manual skill test with one exact server-sealed action."""
|
||||||
|
import asyncio as _asyncio
|
||||||
|
from src.tool_approvals import tool_approval_store
|
||||||
|
|
||||||
|
user = _owner(request)
|
||||||
|
skills = skills_manager.load(owner=user)
|
||||||
|
match = next(
|
||||||
|
(s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(404, "Skill not found")
|
||||||
|
_verify_owner(match, user)
|
||||||
|
name = match.get("name")
|
||||||
|
key = (user or "", name)
|
||||||
|
job = _skill_test_jobs.get(key)
|
||||||
|
if not job or job.get("status") != "awaiting_approval":
|
||||||
|
raise HTTPException(409, "This skill test is not awaiting an approval.")
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Tool approval body must be a JSON object.")
|
||||||
|
approval_id = str(body.get("approval_id") or "")
|
||||||
|
decision = str(body.get("decision") or "").strip().lower()
|
||||||
|
expected = job.get("approval") or {}
|
||||||
|
if approval_id != str(expected.get("approval_id") or ""):
|
||||||
|
raise HTTPException(409, "This approval does not match the pending skill test action.")
|
||||||
|
if decision not in {"approve", "deny"}:
|
||||||
|
raise HTTPException(400, "Invalid tool approval decision.")
|
||||||
|
|
||||||
|
pending = tool_approval_store.peek(approval_id)
|
||||||
|
normalized_owner = str(user or "").strip().casefold()
|
||||||
|
if (
|
||||||
|
pending is None
|
||||||
|
or pending.owner != normalized_owner
|
||||||
|
or pending.session_id != ""
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "This tool approval is invalid or expired.")
|
||||||
|
exact_approval = tool_approval_store.consume(
|
||||||
|
approval_id,
|
||||||
|
decision=decision,
|
||||||
|
owner=user,
|
||||||
|
session_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision == "approve" and exact_approval is None:
|
||||||
|
raise HTTPException(409, "This tool approval could not be consumed.")
|
||||||
|
job.pop("approval", None)
|
||||||
|
if decision == "deny":
|
||||||
|
job.pop("_transcript", None)
|
||||||
|
job.pop("_run", None)
|
||||||
|
job["log"].append({
|
||||||
|
"type": "approval_denied",
|
||||||
|
"text": "Exact action denied; the skill test stopped without executing it.",
|
||||||
|
})
|
||||||
|
job["verdict"] = {
|
||||||
|
"verdict": "inconclusive",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"summary": "The test stopped because its exact action was denied.",
|
||||||
|
"issues": [],
|
||||||
|
}
|
||||||
|
job["status"] = "done"
|
||||||
|
return {"ok": True, "status": "done", "decision": "deny"}
|
||||||
|
|
||||||
|
run = job.get("_run") or {}
|
||||||
|
transcript = job.pop("_transcript", [])
|
||||||
|
# stream_agent_loop owns its per-round message list internally. Rebuild
|
||||||
|
# continuation context from the original untrusted skill plus the
|
||||||
|
# accumulated transcript so repeated approvals do not lose earlier
|
||||||
|
# approved results, while keeping every transcript byte tainted.
|
||||||
|
messages = _skill_test_messages(
|
||||||
|
run.get("md", ""),
|
||||||
|
job.get("task", ""),
|
||||||
|
)
|
||||||
|
if transcript:
|
||||||
|
messages.append(untrusted_context_message(
|
||||||
|
"skill test transcript",
|
||||||
|
"".join(str(item) for item in transcript),
|
||||||
|
))
|
||||||
|
messages.extend([
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": str(expected.get("question") or "Allow this exact action once?"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Approved the exact {exact_approval.pending.tool_name} "
|
||||||
|
"action shown above once."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
job["status"] = "running"
|
||||||
|
job["log"].append({
|
||||||
|
"type": "approval_granted",
|
||||||
|
"text": (
|
||||||
|
f"Approved exact {exact_approval.pending.tool_name} action once; "
|
||||||
|
"resuming test."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
_asyncio.create_task(_run_skill_test_job(
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
run.get("md", ""),
|
||||||
|
job.get("task", ""),
|
||||||
|
run.get("url"),
|
||||||
|
run.get("model"),
|
||||||
|
run.get("headers"),
|
||||||
|
run.get("owner"),
|
||||||
|
skills_manager,
|
||||||
|
messages=messages,
|
||||||
|
transcript=transcript,
|
||||||
|
exact_approval=exact_approval,
|
||||||
|
))
|
||||||
|
return {"ok": True, "status": "running", "decision": "approve"}
|
||||||
|
|
||||||
@router.get("/{skill_id}/test-status")
|
@router.get("/{skill_id}/test-status")
|
||||||
async def test_skill_status(request: Request, skill_id: str):
|
async def test_skill_status(request: Request, skill_id: str):
|
||||||
"""Current background-test state for a skill (status / log / verdict)."""
|
"""Current background-test state for a skill (status / log / verdict)."""
|
||||||
@@ -1459,6 +1692,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
"model": job.get("model"),
|
"model": job.get("model"),
|
||||||
"log": job.get("log", []),
|
"log": job.get("log", []),
|
||||||
"verdict": job.get("verdict"),
|
"verdict": job.get("verdict"),
|
||||||
|
"approval": job.get("approval"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/audit-all")
|
@router.post("/audit-all")
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Make retained SearXNG settings inherit defaults without replacing them."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from yaml.nodes import MappingNode
|
||||||
|
from yaml.tokens import BlockMappingStartToken, FlowMappingStartToken
|
||||||
|
|
||||||
|
|
||||||
|
_UTF8_BOM = b"\xef\xbb\xbf"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_root_mapping(text: str) -> tuple[MappingNode | None, dict]:
|
||||||
|
"""Parse settings with the same safe YAML semantics SearXNG uses."""
|
||||||
|
try:
|
||||||
|
loaded = yaml.safe_load(text)
|
||||||
|
node = yaml.compose(text, Loader=yaml.SafeLoader)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
raise ValueError("settings file is not valid single-document YAML") from None
|
||||||
|
|
||||||
|
if loaded is None and node is None:
|
||||||
|
return None, {}
|
||||||
|
if not isinstance(loaded, dict) or not isinstance(node, MappingNode):
|
||||||
|
raise ValueError("settings root is not a mapping")
|
||||||
|
return node, loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _flow_mapping_start(text: str) -> int:
|
||||||
|
"""Return the root flow mapping's opening-brace character offset."""
|
||||||
|
try:
|
||||||
|
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
||||||
|
if isinstance(token, FlowMappingStartToken):
|
||||||
|
return token.start_mark.index
|
||||||
|
except yaml.YAMLError:
|
||||||
|
pass
|
||||||
|
raise ValueError("flow-style settings mapping has no opening brace")
|
||||||
|
|
||||||
|
|
||||||
|
def _newline_for(contents: bytes) -> bytes:
|
||||||
|
first_lf = contents.find(b"\n")
|
||||||
|
if first_lf > 0 and contents[first_lf - 1 : first_lf + 1] == b"\r\n":
|
||||||
|
return b"\r\n"
|
||||||
|
return b"\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _block_mapping_position(text: str, root: MappingNode | None) -> tuple[int, int]:
|
||||||
|
"""Return a safe character offset and indent for a root block mapping key."""
|
||||||
|
if root is None:
|
||||||
|
return len(text), 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
||||||
|
if not isinstance(token, BlockMappingStartToken):
|
||||||
|
continue
|
||||||
|
line_start = token.start_mark.index - token.start_mark.column
|
||||||
|
if not text[line_start : token.start_mark.index].strip():
|
||||||
|
return line_start, token.start_mark.column
|
||||||
|
return root.end_mark.index, token.start_mark.column
|
||||||
|
except yaml.YAMLError:
|
||||||
|
pass
|
||||||
|
return root.end_mark.index, root.start_mark.column
|
||||||
|
|
||||||
|
|
||||||
|
def _add_block_default_inheritance(
|
||||||
|
contents: bytes, text: str, root: MappingNode | None
|
||||||
|
) -> bytes:
|
||||||
|
newline = _newline_for(contents)
|
||||||
|
character_offset, indent_width = _block_mapping_position(text, root)
|
||||||
|
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
|
||||||
|
offset = bom_length + len(text[:character_offset].encode("utf-8"))
|
||||||
|
separator = b""
|
||||||
|
if offset not in (0, bom_length) and not contents[:offset].endswith((b"\n", b"\r")):
|
||||||
|
separator = newline
|
||||||
|
addition = (
|
||||||
|
separator
|
||||||
|
+ b" " * indent_width
|
||||||
|
+ b"use_default_settings: true"
|
||||||
|
+ newline
|
||||||
|
)
|
||||||
|
return contents[:offset] + addition + contents[offset:]
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_settings(path: Path) -> bool:
|
||||||
|
"""Add the missing inheritance key atomically; return whether the file changed."""
|
||||||
|
source_stat = path.lstat()
|
||||||
|
if not stat.S_ISREG(source_stat.st_mode):
|
||||||
|
raise ValueError(f"settings path is not a regular file: {path}")
|
||||||
|
|
||||||
|
contents = path.read_bytes()
|
||||||
|
if not contents:
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = contents.decode("utf-8-sig")
|
||||||
|
root, loaded = _parse_root_mapping(text)
|
||||||
|
if "use_default_settings" in loaded:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if root is not None and root.flow_style:
|
||||||
|
start = _flow_mapping_start(text)
|
||||||
|
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
|
||||||
|
offset = bom_length + len(text[: start + 1].encode("utf-8"))
|
||||||
|
separator = b", " if root.value else b""
|
||||||
|
updated = (
|
||||||
|
contents[:offset]
|
||||||
|
+ b"use_default_settings: true"
|
||||||
|
+ separator
|
||||||
|
+ contents[offset:]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
updated = _add_block_default_inheritance(contents, text, root)
|
||||||
|
fd, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{path.name}.odysseus-", dir=path.parent
|
||||||
|
)
|
||||||
|
temporary = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
# chmod before chown: the Compose cap set is `cap_drop: ALL` plus
|
||||||
|
# CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary
|
||||||
|
# file belongs to searxng:searxng — which every retained settings file
|
||||||
|
# does, because searxng's entrypoint chowns /etc/searxng — root can no
|
||||||
|
# longer chmod it and the migration dies with EPERM.
|
||||||
|
os.fchmod(fd, stat.S_IMODE(source_stat.st_mode))
|
||||||
|
os.fchown(fd, source_stat.st_uid, source_stat.st_gid)
|
||||||
|
with os.fdopen(fd, "wb") as handle:
|
||||||
|
fd = -1
|
||||||
|
handle.write(updated)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
finally:
|
||||||
|
if fd >= 0:
|
||||||
|
os.close(fd)
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) > 2:
|
||||||
|
print(f"usage: {Path(argv[0]).name} [settings.yml]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
path = Path(argv[1]) if len(argv) == 2 else Path("/etc/searxng/settings.yml")
|
||||||
|
try:
|
||||||
|
changed = migrate_settings(path)
|
||||||
|
except (OSError, UnicodeError, ValueError) as exc:
|
||||||
|
print(f"SearXNG settings migration failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
print("Added use_default_settings inheritance to retained SearXNG settings")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
|
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, Iterable, List, Optional, Tuple, cast
|
||||||
from urllib.parse import quote, urljoin, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
|
import httpcore
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import _default_resolver, check_outbound_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
|
|||||||
_GITHUB_HOSTS = frozenset({
|
_GITHUB_HOSTS = frozenset({
|
||||||
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
|
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
|
||||||
})
|
})
|
||||||
|
_SKILLS_SH_HOSTS = frozenset({"skills.sh", "www.skills.sh"})
|
||||||
|
|
||||||
|
|
||||||
def _github_host(url: str) -> str:
|
def _github_host(url: str) -> str:
|
||||||
@@ -72,18 +75,158 @@ def _is_text_file(name: str) -> bool:
|
|||||||
_MAX_FETCH_REDIRECTS = 5
|
_MAX_FETCH_REDIRECTS = 5
|
||||||
|
|
||||||
|
|
||||||
def _check_fetch_url(url: str) -> None:
|
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
|
||||||
"""SSRF guard for skill-import fetches (defense-in-depth).
|
"""Parse and de-duplicate one resolver snapshot in resolver order."""
|
||||||
|
ips: List[ipaddress._BaseAddress] = []
|
||||||
|
seen = set()
|
||||||
|
for raw in raw_ips:
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(raw.split("%", 1)[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if ip in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ip)
|
||||||
|
ips.append(ip)
|
||||||
|
return ips
|
||||||
|
|
||||||
Skill bundles only ever come from public GitHub, never an internal
|
|
||||||
address, so block private/loopback/link-local targets on every hop —
|
def _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:
|
||||||
matching the hardened web-fetch path in
|
"""Return the exact address snapshot approved for one fetch hop."""
|
||||||
``services/search/content.py:_get_public_url`` rather than the lenient
|
resolved_ips: List[str] = []
|
||||||
default used for admin-configured model endpoints.
|
|
||||||
"""
|
def _recording_resolver(host: str) -> List[str]:
|
||||||
ok, reason = check_outbound_url(url, block_private=True)
|
answers = list(_default_resolver(host))
|
||||||
|
resolved_ips[:] = answers
|
||||||
|
return answers
|
||||||
|
|
||||||
|
ok, reason = check_outbound_url(
|
||||||
|
url,
|
||||||
|
block_private=True,
|
||||||
|
resolver=_recording_resolver,
|
||||||
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise SkillImportError(reason)
|
raise SkillImportError(f"outbound URL blocked: {reason}")
|
||||||
|
|
||||||
|
pinned_ips = _validated_ips(resolved_ips)
|
||||||
|
if not pinned_ips:
|
||||||
|
raise SkillImportError("outbound URL blocked: host did not resolve to a usable address")
|
||||||
|
return pinned_ips
|
||||||
|
|
||||||
|
|
||||||
|
# Backward compatibility alias for tests importing _check_fetch_url directly
|
||||||
|
_check_fetch_url = _resolve_and_check_url
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedBackend(httpcore.NetworkBackend):
|
||||||
|
"""Connect only to addresses from one validated DNS snapshot."""
|
||||||
|
|
||||||
|
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||||
|
self._ips = [str(ip) for ip in ips]
|
||||||
|
self._real = httpcore.SyncBackend()
|
||||||
|
|
||||||
|
def connect_tcp(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | None = None,
|
||||||
|
local_address: str | None = None,
|
||||||
|
socket_options=None,
|
||||||
|
):
|
||||||
|
deadline = None if timeout is None else time.monotonic() + timeout
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
for ip in self._ips:
|
||||||
|
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||||
|
try:
|
||||||
|
return self._real.connect_tcp(
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
remaining,
|
||||||
|
local_address,
|
||||||
|
socket_options,
|
||||||
|
)
|
||||||
|
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if deadline is not None and time.monotonic() >= deadline:
|
||||||
|
break
|
||||||
|
if last_exc is not None:
|
||||||
|
raise last_exc
|
||||||
|
raise httpcore.ConnectError("no validated address available")
|
||||||
|
|
||||||
|
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||||
|
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||||
|
|
||||||
|
def sleep(self, seconds: float) -> None:
|
||||||
|
return self._real.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
_HTTPCORE_TO_HTTPX_EXC = {
|
||||||
|
httpcore.ConnectError: httpx.ConnectError,
|
||||||
|
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||||
|
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||||
|
httpcore.NetworkError: httpx.NetworkError,
|
||||||
|
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||||
|
httpcore.ProtocolError: httpx.ProtocolError,
|
||||||
|
httpcore.ProxyError: httpx.ProxyError,
|
||||||
|
httpcore.ReadError: httpx.ReadError,
|
||||||
|
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||||
|
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||||
|
httpcore.TimeoutException: httpx.TimeoutException,
|
||||||
|
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||||
|
httpcore.WriteError: httpx.WriteError,
|
||||||
|
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedTransport(httpx.BaseTransport):
|
||||||
|
"""Pin socket connects while preserving URL authority, Host, and TLS SNI."""
|
||||||
|
|
||||||
|
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||||
|
self._pinned_ips = list(ips)
|
||||||
|
self._pool = httpcore.ConnectionPool(
|
||||||
|
ssl_context=httpx.create_ssl_context(),
|
||||||
|
http1=True,
|
||||||
|
http2=False,
|
||||||
|
network_backend=_PinnedBackend(ips),
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
core_request = httpcore.Request(
|
||||||
|
method=request.method,
|
||||||
|
url=httpcore.URL(
|
||||||
|
scheme=request.url.raw_scheme,
|
||||||
|
host=request.url.raw_host,
|
||||||
|
port=request.url.port,
|
||||||
|
target=request.url.raw_path,
|
||||||
|
),
|
||||||
|
headers=request.headers.raw,
|
||||||
|
content=request.stream,
|
||||||
|
extensions=request.extensions,
|
||||||
|
)
|
||||||
|
core_response = None
|
||||||
|
try:
|
||||||
|
core_response = self._pool.handle_request(core_request)
|
||||||
|
content = b"".join(cast(Iterable[bytes], core_response.stream))
|
||||||
|
except Exception as exc:
|
||||||
|
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||||
|
if mapped is not None:
|
||||||
|
raise mapped(str(exc)) from exc
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if core_response is not None:
|
||||||
|
core_response.close()
|
||||||
|
|
||||||
|
return httpx.Response(
|
||||||
|
status_code=core_response.status,
|
||||||
|
headers=core_response.headers,
|
||||||
|
content=content,
|
||||||
|
extensions=core_response.extensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._pool.close()
|
||||||
|
|
||||||
|
|
||||||
def _get_checked(
|
def _get_checked(
|
||||||
@@ -100,49 +243,76 @@ def _get_checked(
|
|||||||
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
||||||
"""
|
"""
|
||||||
current = url
|
current = url
|
||||||
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
|
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||||
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
pinned_ips = _resolve_and_check_url(current)
|
||||||
_check_fetch_url(current)
|
with httpx.Client(
|
||||||
|
transport=_PinnedTransport(pinned_ips),
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
r = client.get(current, headers=headers)
|
r = client.get(current, headers=headers)
|
||||||
if r.status_code in (301, 302, 303, 307, 308):
|
|
||||||
location = r.headers.get("location")
|
if r.status_code in (301, 302, 303, 307, 308):
|
||||||
if not location:
|
location = r.headers.get("location")
|
||||||
return r
|
if not location:
|
||||||
current = urljoin(str(r.url), location)
|
return r
|
||||||
continue
|
current = urljoin(str(r.url), location)
|
||||||
return r
|
continue
|
||||||
|
return r
|
||||||
raise SkillImportError("too many redirects while fetching skill bundle")
|
raise SkillImportError("too many redirects while fetching skill bundle")
|
||||||
|
|
||||||
|
|
||||||
def parse_skill_source(url: str) -> ResolvedSource:
|
def parse_skill_source(url: str) -> ResolvedSource:
|
||||||
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
||||||
raw = (url or "").strip()
|
url = (url or "").strip()
|
||||||
if not raw:
|
if not url:
|
||||||
raise SkillImportError("URL is required")
|
raise SkillImportError("URL is required")
|
||||||
|
|
||||||
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
|
# ``urlparse`` only reports an unambiguous scheme when the URL carries the
|
||||||
if "skills.sh" in raw and "github.com" not in raw:
|
# ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a
|
||||||
r = _get_checked(raw, timeout=20.0)
|
# schemeless ``host:port`` both parse a "scheme" that is not one, so they
|
||||||
|
# fall through to the host check below and are rejected on the host instead.
|
||||||
|
scheme = urlparse(url).scheme.lower()
|
||||||
|
if scheme not in ("http", "https"):
|
||||||
|
if scheme and url.lower().startswith(f"{scheme}://"):
|
||||||
|
raise SkillImportError(f"unsupported URL scheme: {scheme}")
|
||||||
|
# Schemeless "github.com/owner/repo" — accept only a supported host.
|
||||||
|
rough_host = (urlparse("//" + url).hostname or "").lower()
|
||||||
|
if rough_host not in _GITHUB_HOSTS and rough_host not in _SKILLS_SH_HOSTS:
|
||||||
|
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
|
||||||
|
url = "https://" + url
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
if hostname not in _GITHUB_HOSTS and hostname not in _SKILLS_SH_HOSTS:
|
||||||
|
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
|
||||||
|
|
||||||
|
# A skills.sh link is only usable if it redirects to an exact supported
|
||||||
|
# GitHub host. Scraping the page body for a github.com link cannot work:
|
||||||
|
# skill pages only ever link the repository root, never the skill's
|
||||||
|
# subdirectory, so the scrape resolves every skill in a repo to the same
|
||||||
|
# (wrong) bundle. Fail with an actionable message instead.
|
||||||
|
if hostname in _SKILLS_SH_HOSTS:
|
||||||
|
r = _get_checked(url, timeout=20.0)
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
raise _github_response_error(r)
|
raise _github_response_error(r)
|
||||||
final = str(r.url)
|
final = str(r.url)
|
||||||
_assert_github_url(final, context="redirect target")
|
if _github_host(final) not in _GITHUB_HOSTS:
|
||||||
# Page may embed a github link; prefer final URL if redirected.
|
raise SkillImportError(
|
||||||
if "github.com" in final:
|
"skills.sh did not redirect to GitHub — open the skill's "
|
||||||
raw = final
|
"repository on GitHub, navigate to the exact skill folder or "
|
||||||
else:
|
"SKILL.md file, and paste that URL; the repository-root link "
|
||||||
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
|
"alone is not sufficient"
|
||||||
if m:
|
)
|
||||||
raw = m.group(0).rstrip(".,)")
|
url = final
|
||||||
|
|
||||||
parsed = urlparse(raw)
|
# Update parsed and hostname to reflect the new GitHub URL
|
||||||
host = _github_host(raw)
|
parsed = urlparse(url)
|
||||||
if host not in _GITHUB_HOSTS:
|
hostname = (parsed.hostname or "").lower()
|
||||||
raise SkillImportError(
|
|
||||||
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if host == "raw.githubusercontent.com":
|
_assert_github_url(url)
|
||||||
|
|
||||||
|
if hostname == "raw.githubusercontent.com":
|
||||||
# /owner/repo/ref/path/to/file
|
# /owner/repo/ref/path/to/file
|
||||||
bits = [p for p in parsed.path.split("/") if p]
|
bits = [p for p in parsed.path.split("/") if p]
|
||||||
if len(bits) < 4:
|
if len(bits) < 4:
|
||||||
|
|||||||
+1636
-464
File diff suppressed because it is too large
Load Diff
+84
-26
@@ -17,13 +17,14 @@ close / navigation / refresh). It does NOT survive a server restart.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import AsyncGenerator, Dict, Optional
|
from typing import AsyncGenerator, Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _Run:
|
class _Run:
|
||||||
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task")
|
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task", "run_id")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.buffer: list = [] # ordered SSE event strings (replay log)
|
self.buffer: list = [] # ordered SSE event strings (replay log)
|
||||||
@@ -31,6 +32,9 @@ class _Run:
|
|||||||
self.status: str = "running" # running | done | error | stopped
|
self.status: str = "running" # running | done | error | stopped
|
||||||
self.task: Optional[asyncio.Task] = None
|
self.task: Optional[asyncio.Task] = None
|
||||||
self.evict_task: Optional[asyncio.Task] = None
|
self.evict_task: Optional[asyncio.Task] = None
|
||||||
|
# Stable across every subscription/replay of this exact detached run.
|
||||||
|
# The browser uses it to make local cost accounting replay-idempotent.
|
||||||
|
self.run_id: str = uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
_RUNS: Dict[str, _Run] = {}
|
_RUNS: Dict[str, _Run] = {}
|
||||||
@@ -53,13 +57,24 @@ def _publish(run: _Run, ev: str) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _schedule_evict(session_id: str) -> None:
|
def _wake_run_subscribers(run: _Run) -> None:
|
||||||
|
"""Close subscribers even when the drain task never reached its body."""
|
||||||
|
for q in list(run.subscribers):
|
||||||
|
try:
|
||||||
|
q.put_nowait((None, None))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_evict(session_id: str, expected_run: Optional[_Run] = None) -> None:
|
||||||
"""(Re)arm a grace-period eviction for a terminal run with no subscribers.
|
"""(Re)arm a grace-period eviction for a terminal run with no subscribers.
|
||||||
Identity-checked so a run that gets replaced/reused is never evicted by a
|
Identity-checked so a run that gets replaced/reused is never evicted by a
|
||||||
stale timer."""
|
stale timer."""
|
||||||
run = _RUNS.get(session_id)
|
run = _RUNS.get(session_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
return
|
return
|
||||||
|
if expected_run is not None and run is not expected_run:
|
||||||
|
return
|
||||||
if run.evict_task and not run.evict_task.done():
|
if run.evict_task and not run.evict_task.done():
|
||||||
run.evict_task.cancel()
|
run.evict_task.cancel()
|
||||||
|
|
||||||
@@ -85,25 +100,38 @@ def get_status(session_id: str) -> Optional[str]:
|
|||||||
return r.status if r else None
|
return r.status if r else None
|
||||||
|
|
||||||
|
|
||||||
async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
def get_run_id(session_id: str) -> Optional[str]:
|
||||||
|
"""Return the opaque identity of the current detached run, if present."""
|
||||||
|
r = _RUNS.get(session_id)
|
||||||
|
return r.run_id if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_run(session_id: str) -> Optional[_Run]:
|
||||||
|
"""Return the exact active run currently registered for a session."""
|
||||||
|
r = _RUNS.get(session_id)
|
||||||
|
return r if r and r.status == "running" else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain(session_id: str, run: _Run, agen: AsyncGenerator[str, None],
|
||||||
prev_task: Optional[asyncio.Task] = None) -> None:
|
prev_task: Optional[asyncio.Task] = None) -> None:
|
||||||
"""Pull every event from the wrapped generator into the run buffer, fanning
|
"""Pull every event from the wrapped generator into the run buffer, fanning
|
||||||
each out to live subscribers. Runs to completion regardless of subscribers."""
|
each out to live subscribers. Runs to completion regardless of subscribers."""
|
||||||
run = _RUNS.get(session_id)
|
subscribers_woken = False
|
||||||
if run is None:
|
|
||||||
return
|
def _wake_subscribers() -> None:
|
||||||
|
nonlocal subscribers_woken
|
||||||
|
if subscribers_woken:
|
||||||
|
return
|
||||||
|
subscribers_woken = True
|
||||||
|
_wake_run_subscribers(run)
|
||||||
|
|
||||||
# If this run replaced an in-flight one (rapid double-send), wait for that
|
# If this run replaced an in-flight one (rapid double-send), wait for that
|
||||||
# one to fully finish first. Its CancelledError handler calls aclose(), which
|
# one to fully finish first. Its CancelledError handler calls aclose(), which
|
||||||
# persists its partial response — letting it complete before we start writing
|
# persists its partial response — letting it complete before we start writing
|
||||||
# keeps the two runs' session saves sequential instead of interleaved.
|
# keeps the two runs' session saves sequential instead of interleaved.
|
||||||
if prev_task is not None and not prev_task.done():
|
|
||||||
try:
|
|
||||||
await asyncio.wait({prev_task})
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise # our own cancellation — propagate
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
|
if prev_task is not None and not prev_task.done():
|
||||||
|
await asyncio.wait({prev_task})
|
||||||
async for ev in agen:
|
async for ev in agen:
|
||||||
_publish(run, ev)
|
_publish(run, ev)
|
||||||
if run.status == "running":
|
if run.status == "running":
|
||||||
@@ -116,6 +144,16 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
|||||||
await agen.aclose()
|
await agen.aclose()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# A rapid third replacement can cancel this task while it is still
|
||||||
|
# waiting for its predecessor. Close this run's subscribers promptly,
|
||||||
|
# but keep the task alive until the predecessor finishes so the next
|
||||||
|
# run still observes the transitive session-save ordering barrier.
|
||||||
|
_wake_subscribers()
|
||||||
|
if prev_task is not None and not prev_task.done():
|
||||||
|
try:
|
||||||
|
await asyncio.shield(prev_task)
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
|
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
|
||||||
run.status = "error"
|
run.status = "error"
|
||||||
@@ -127,15 +165,11 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
|||||||
_publish(run, "data: [DONE]\n\n")
|
_publish(run, "data: [DONE]\n\n")
|
||||||
finally:
|
finally:
|
||||||
# Wake every subscriber with the end sentinel so their SSE closes.
|
# Wake every subscriber with the end sentinel so their SSE closes.
|
||||||
for q in list(run.subscribers):
|
_wake_subscribers()
|
||||||
try:
|
|
||||||
q.put_nowait((None, None))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Run is terminal — arm the grace timer so it (and its buffer) is
|
# Run is terminal — arm the grace timer so it (and its buffer) is
|
||||||
# eventually freed even if nobody ever reconnects. subscribe() cancels
|
# eventually freed even if nobody ever reconnects. subscribe() cancels
|
||||||
# this on connect and re-arms on disconnect.
|
# this on connect and re-arms on disconnect.
|
||||||
_schedule_evict(session_id)
|
_schedule_evict(session_id, run)
|
||||||
|
|
||||||
|
|
||||||
def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
|
def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
|
||||||
@@ -145,20 +179,37 @@ def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
|
|||||||
prev_task: Optional[asyncio.Task] = None
|
prev_task: Optional[asyncio.Task] = None
|
||||||
if prev:
|
if prev:
|
||||||
if prev.task and not prev.task.done():
|
if prev.task and not prev.task.done():
|
||||||
|
# A task cancelled before its first instruction never enters
|
||||||
|
# _drain(), so its except/finally blocks cannot update status or
|
||||||
|
# wake a response already bound to this exact run. Terminalize it
|
||||||
|
# synchronously before cancelling; _drain's cleanup is idempotent
|
||||||
|
# when the task had already started.
|
||||||
|
if prev.status == "running":
|
||||||
|
prev.status = "stopped"
|
||||||
|
_wake_run_subscribers(prev)
|
||||||
prev.task.cancel()
|
prev.task.cancel()
|
||||||
prev_task = prev.task # new run awaits this before it starts writing
|
prev_task = prev.task # new run awaits this before it starts writing
|
||||||
if prev.evict_task and not prev.evict_task.done():
|
if prev.evict_task and not prev.evict_task.done():
|
||||||
prev.evict_task.cancel()
|
prev.evict_task.cancel()
|
||||||
run = _Run()
|
run = _Run()
|
||||||
_RUNS[session_id] = run
|
_RUNS[session_id] = run
|
||||||
run.task = asyncio.create_task(_drain(session_id, agen, prev_task))
|
run.task = asyncio.create_task(_drain(session_id, run, agen, prev_task))
|
||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
|
async def subscribe(
|
||||||
|
session_id: str,
|
||||||
|
expected_run: Optional[_Run] = None,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Replay the run's buffer from the start, then stream live until it ends.
|
"""Replay the run's buffer from the start, then stream live until it ends.
|
||||||
Safe to call repeatedly (reconnect) and from multiple clients at once."""
|
Safe to call repeatedly (reconnect) and from multiple clients at once.
|
||||||
run = _RUNS.get(session_id)
|
|
||||||
|
``expected_run`` binds a lazy StreamingResponse body to the same run whose
|
||||||
|
identity was put in its response headers. Without that binding, a rapid
|
||||||
|
replacement between response construction and body iteration could replay
|
||||||
|
the replacement run under the prior run's identity.
|
||||||
|
"""
|
||||||
|
run = expected_run or _RUNS.get(session_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
return
|
return
|
||||||
q: asyncio.Queue = asyncio.Queue()
|
q: asyncio.Queue = asyncio.Queue()
|
||||||
@@ -201,12 +252,19 @@ async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
|
|||||||
# Last subscriber gone on a finished run — (re)arm eviction so the
|
# Last subscriber gone on a finished run — (re)arm eviction so the
|
||||||
# buffer doesn't linger indefinitely.
|
# buffer doesn't linger indefinitely.
|
||||||
if not run.subscribers and run.status != "running":
|
if not run.subscribers and run.status != "running":
|
||||||
_schedule_evict(session_id)
|
_schedule_evict(session_id, run)
|
||||||
|
|
||||||
|
|
||||||
def stop(session_id: str) -> bool:
|
def stop(session_id: str, expected_run_id: Optional[str] = None) -> bool:
|
||||||
"""Cancel an in-flight run (the wrapped generator saves its partial)."""
|
"""Cancel the matching in-flight run (which saves its partial output).
|
||||||
|
|
||||||
|
A stale browser may issue Stop after another tab has replaced the session's
|
||||||
|
run. Once the caller knows its opaque run identity, fail closed rather than
|
||||||
|
cancelling that newer run.
|
||||||
|
"""
|
||||||
run = _RUNS.get(session_id)
|
run = _RUNS.get(session_id)
|
||||||
|
if not expected_run_id or run is None or run.run_id != expected_run_id:
|
||||||
|
return False
|
||||||
if run and run.task and not run.task.done():
|
if run and run.task and not run.task.done():
|
||||||
run.task.cancel()
|
run.task.cancel()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -510,7 +510,12 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
# set/get/list/delete operate on the REAL app settings (the same store
|
# set/get/list/delete operate on the REAL app settings (the same store
|
||||||
# the Settings panel writes), so changing a model / voice / search
|
# the Settings panel writes), so changing a model / voice / search
|
||||||
# engine / reminder channel from chat actually takes effect.
|
# engine / reminder channel from chat actually takes effect.
|
||||||
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
|
from src.settings import (
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
RETIRED_SETTING_KEYS,
|
||||||
|
load_settings,
|
||||||
|
save_settings,
|
||||||
|
)
|
||||||
|
|
||||||
# Secrets/credentials the agent must NOT write: kept read-only (masked)
|
# Secrets/credentials the agent must NOT write: kept read-only (masked)
|
||||||
# so API keys never flow through chat. User sets these in the panel.
|
# so API keys never flow through chat. User sets these in the panel.
|
||||||
@@ -562,6 +567,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
return k2
|
return k2
|
||||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||||
|
|
||||||
|
def _is_managed_key(key):
|
||||||
|
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
||||||
|
|
||||||
_ENUMS = {
|
_ENUMS = {
|
||||||
"image_quality": ["low", "medium", "high"],
|
"image_quality": ["low", "medium", "high"],
|
||||||
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
||||||
@@ -624,14 +632,18 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
|
|
||||||
if action == "list":
|
if action == "list":
|
||||||
s = load_settings()
|
s = load_settings()
|
||||||
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
|
shown = {
|
||||||
|
k: _mask(k, v)
|
||||||
|
for k, v in s.items()
|
||||||
|
if _is_managed_key(k) and not isinstance(v, dict)
|
||||||
|
}
|
||||||
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
||||||
|
|
||||||
elif action == "get":
|
elif action == "get":
|
||||||
key = _resolve(args.get("key", ""))
|
key = _resolve(args.get("key", ""))
|
||||||
if not key:
|
if not key:
|
||||||
return {"error": "key is required", "exit_code": 1}
|
return {"error": "key is required", "exit_code": 1}
|
||||||
if key not in DEFAULT_SETTINGS:
|
if not _is_managed_key(key):
|
||||||
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
||||||
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
||||||
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
||||||
@@ -642,11 +654,11 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
if not raw:
|
if not raw:
|
||||||
return {"error": "key is required", "exit_code": 1}
|
return {"error": "key is required", "exit_code": 1}
|
||||||
key = _resolve(raw)
|
key = _resolve(raw)
|
||||||
if key not in DEFAULT_SETTINGS:
|
if not _is_managed_key(key):
|
||||||
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
||||||
if _is_secret(key):
|
if _is_secret(key):
|
||||||
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
||||||
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
|
# Structured settings (dicts/lists like keybinds or vision fallbacks)
|
||||||
# have no safe scalar coercion; _coerce would pass a bare string
|
# have no safe scalar coercion; _coerce would pass a bare string
|
||||||
# straight through and clobber the structure. Refuse them here; they're
|
# straight through and clobber the structure. Refuse them here; they're
|
||||||
# edited in their dedicated panels. (reset/delete still restore the
|
# edited in their dedicated panels. (reset/delete still restore the
|
||||||
@@ -675,7 +687,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
|
|
||||||
elif action == "delete" or action == "reset":
|
elif action == "delete" or action == "reset":
|
||||||
key = _resolve(args.get("key", ""))
|
key = _resolve(args.get("key", ""))
|
||||||
if key not in DEFAULT_SETTINGS:
|
if not _is_managed_key(key):
|
||||||
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
||||||
if _is_secret(key):
|
if _is_secret(key):
|
||||||
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from src.constants import MAX_READ_CHARS
|
from src.constants import MAX_READ_CHARS
|
||||||
|
from src.tool_approvals import document_content_digest
|
||||||
from src.tool_utils import _parse_tool_args, get_upload_handler
|
from src.tool_utils import _parse_tool_args, get_upload_handler
|
||||||
from src.upload_handler import reserve_upload_references
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
@@ -80,6 +81,40 @@ def _most_recent_owned_document(db, Document, owner: Optional[str], active_only:
|
|||||||
return q.order_by(Document.updated_at.desc()).first()
|
return q.order_by(Document.updated_at.desc()).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _approved_document_version_error(doc: Any, ctx: dict) -> Optional[Dict]:
|
||||||
|
"""Reject a sealed document action when its target changed meanwhile."""
|
||||||
|
expected_version = ctx.get("expected_document_version")
|
||||||
|
expected_digest = (
|
||||||
|
str(ctx.get("expected_document_digest") or "").strip().lower()
|
||||||
|
)
|
||||||
|
if expected_version is None and not expected_digest:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
version_unchanged = (
|
||||||
|
expected_version is None
|
||||||
|
or int(getattr(doc, "version_count", -1)) == int(expected_version)
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
version_unchanged = False
|
||||||
|
content_unchanged = True
|
||||||
|
if expected_digest:
|
||||||
|
content_unchanged = (
|
||||||
|
doc is not None
|
||||||
|
and document_content_digest(getattr(doc, "current_content", ""))
|
||||||
|
== expected_digest
|
||||||
|
)
|
||||||
|
if version_unchanged and content_unchanged:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"error": (
|
||||||
|
"The target document changed after this action was proposed. "
|
||||||
|
"Review the latest version and request the edit again."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
"document_changed": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Document tools — create/update/edit/suggest living documents
|
# Document tools — create/update/edit/suggest living documents
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -454,6 +489,12 @@ class UpdateDocumentTool:
|
|||||||
doc = None
|
doc = None
|
||||||
if target_id:
|
if target_id:
|
||||||
doc = _get_owned_document(db, Document, target_id, owner)
|
doc = _get_owned_document(db, Document, target_id, owner)
|
||||||
|
if (
|
||||||
|
not doc
|
||||||
|
and target_id
|
||||||
|
and ctx.get("expected_document_version") is not None
|
||||||
|
):
|
||||||
|
return _approved_document_version_error(None, ctx)
|
||||||
if not doc:
|
if not doc:
|
||||||
doc = _most_recent_owned_document(db, Document, owner)
|
doc = _most_recent_owned_document(db, Document, owner)
|
||||||
if doc:
|
if doc:
|
||||||
@@ -463,6 +504,10 @@ class UpdateDocumentTool:
|
|||||||
if not doc:
|
if not doc:
|
||||||
return {"error": "No documents exist to update"}
|
return {"error": "No documents exist to update"}
|
||||||
|
|
||||||
|
version_error = _approved_document_version_error(doc, ctx)
|
||||||
|
if version_error:
|
||||||
|
return version_error
|
||||||
|
|
||||||
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
||||||
new_content = _coerce_email_document_content(doc.current_content or "", content) if is_email_doc else content.strip()
|
new_content = _coerce_email_document_content(doc.current_content or "", content) if is_email_doc else content.strip()
|
||||||
if is_email_doc:
|
if is_email_doc:
|
||||||
@@ -530,6 +575,12 @@ class EditDocumentTool:
|
|||||||
doc = None
|
doc = None
|
||||||
if target_id:
|
if target_id:
|
||||||
doc = _get_owned_document(db, Document, target_id, owner)
|
doc = _get_owned_document(db, Document, target_id, owner)
|
||||||
|
if (
|
||||||
|
not doc
|
||||||
|
and target_id
|
||||||
|
and ctx.get("expected_document_version") is not None
|
||||||
|
):
|
||||||
|
return _approved_document_version_error(None, ctx)
|
||||||
if not doc:
|
if not doc:
|
||||||
# Fallback: most recently updated document. Avoids "no active doc" errors
|
# Fallback: most recently updated document. Avoids "no active doc" errors
|
||||||
# after server restart or when the agent loses track of which doc to edit.
|
# after server restart or when the agent loses track of which doc to edit.
|
||||||
@@ -541,6 +592,10 @@ class EditDocumentTool:
|
|||||||
if not doc:
|
if not doc:
|
||||||
return {"error": "No documents exist to edit"}
|
return {"error": "No documents exist to edit"}
|
||||||
|
|
||||||
|
version_error = _approved_document_version_error(doc, ctx)
|
||||||
|
if version_error:
|
||||||
|
return version_error
|
||||||
|
|
||||||
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
||||||
blank_find_edits = [e for e in edits if not (e.get("find") or "").strip()]
|
blank_find_edits = [e for e in edits if not (e.get("find") or "").strip()]
|
||||||
if blank_find_edits:
|
if blank_find_edits:
|
||||||
@@ -677,6 +732,10 @@ class SuggestDocumentTool:
|
|||||||
if not doc:
|
if not doc:
|
||||||
return {"error": f"Document {target_id} not found"}
|
return {"error": f"Document {target_id} not found"}
|
||||||
|
|
||||||
|
version_error = _approved_document_version_error(doc, ctx)
|
||||||
|
if version_error:
|
||||||
|
return version_error
|
||||||
|
|
||||||
# Validate that FIND text exists in document
|
# Validate that FIND text exists in document
|
||||||
valid = []
|
valid = []
|
||||||
for s in suggestions:
|
for s in suggestions:
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ async def chat_with_model(content: str, session_id: Optional[str] = None, owner:
|
|||||||
return {"model": model, "response": response}
|
return {"model": model, "response": response}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"chat_with_model failed: {e}")
|
logger.error(f"chat_with_model failed: {e}")
|
||||||
return {"error": f"Failed to get response from {model_spec}: {e}"}
|
return {
|
||||||
|
"error": f"Failed to get response from {model_spec}: {e}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
@@ -110,7 +113,10 @@ async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Opt
|
|||||||
return {"model": model, "response": response, "teacher": True}
|
return {"model": model, "response": response, "teacher": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ask_teacher failed: {e}")
|
logger.error(f"ask_teacher failed: {e}")
|
||||||
return {"error": f"Teacher call failed ({model_spec}): {e}"}
|
return {
|
||||||
|
"error": f"Teacher call failed ({model_spec}): {e}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def list_models(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
async def list_models(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
|
|||||||
@@ -240,7 +240,10 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"send_to_session failed: {e}")
|
logger.error(f"send_to_session failed: {e}")
|
||||||
return {"error": f"Failed to send to session: {e}"}
|
return {
|
||||||
|
"error": f"Failed to send to session: {e}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
async def manage_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
async def manage_session(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||||
"""Manage sessions: rename, archive, delete, important, truncate, fork.
|
"""Manage sessions: rename, archive, delete, important, truncate, fork.
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class WebSearchTool:
|
|||||||
return {
|
return {
|
||||||
"error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}",
|
"error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}",
|
||||||
"exit_code": 1,
|
"exit_code": 1,
|
||||||
|
"untrusted_content": True,
|
||||||
}
|
}
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
await progress_cb({
|
await progress_cb({
|
||||||
@@ -136,7 +137,11 @@ class WebFetchTool:
|
|||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
if err:
|
if err:
|
||||||
return {"error": f"web_fetch: {url}: {err}", "exit_code": 1}
|
return {
|
||||||
|
"error": f"web_fetch: {url}: {err}",
|
||||||
|
"exit_code": 1,
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1}
|
return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1}
|
||||||
|
|
||||||
# Tell the model when the download budget cut the body short and how
|
# Tell the model when the download budget cut the body short and how
|
||||||
|
|||||||
+24
-6
@@ -324,7 +324,10 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"pipeline failed at step {len(step_outputs) + 1}: {e}")
|
logger.error(f"pipeline failed at step {len(step_outputs) + 1}: {e}")
|
||||||
return {"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}"}
|
return {
|
||||||
|
"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1089,7 +1092,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
error_text = err_json.get("error", {}).get("message", error_text) if isinstance(err_json.get("error"), dict) else str(err_json.get("error", error_text))
|
error_text = err_json.get("error", {}).get("message", error_text) if isinstance(err_json.get("error"), dict) else str(err_json.get("error", error_text))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"error": f"Image generation failed ({resp.status_code}): {error_text}"}
|
return {
|
||||||
|
"error": f"Image generation failed ({resp.status_code}): {error_text}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
images = data.get("data", [])
|
images = data.get("data", [])
|
||||||
@@ -1173,7 +1179,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
return {"error": "Image generation timed out (300s). The model may be overloaded — try again or use quality=low."}
|
return {"error": "Image generation timed out (300s). The model may be overloaded — try again or use quality=low."}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Image generation error: {str(e)}"}
|
return {
|
||||||
|
"error": f"Image generation error: {str(e)}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def do_edit_image(
|
async def do_edit_image(
|
||||||
@@ -1310,7 +1319,10 @@ async def do_edit_image(
|
|||||||
error_text = err_json.get("detail") or err_json.get("error") or error_text
|
error_text = err_json.get("detail") or err_json.get("error") or error_text
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}"}
|
return {
|
||||||
|
"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
fallback_data = fallback_resp.json()
|
fallback_data = fallback_resp.json()
|
||||||
image_b64 = fallback_data.get("image")
|
image_b64 = fallback_data.get("image")
|
||||||
if not image_b64:
|
if not image_b64:
|
||||||
@@ -1394,7 +1406,10 @@ async def do_edit_image(
|
|||||||
"model for attached-image prompts."
|
"model for attached-image prompts."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return {"error": f"Image edit failed ({resp.status_code}): {error_text}"}
|
return {
|
||||||
|
"error": f"Image edit failed ({resp.status_code}): {error_text}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
images = data.get("data", [])
|
images = data.get("data", [])
|
||||||
@@ -1434,7 +1449,10 @@ async def do_edit_image(
|
|||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
|
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Image edit error: {str(e)}"}
|
return {
|
||||||
|
"error": f"Image edit error: {str(e)}",
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+13
-1
@@ -4,6 +4,8 @@ import os
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import Request, HTTPException
|
from fastapi import Request, HTTPException
|
||||||
|
|
||||||
|
from src.owner_identity import auth_disabled, effective_storage_owner
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request) -> Optional[str]:
|
def get_current_user(request: Request) -> Optional[str]:
|
||||||
"""Get current username from request state (set by auth middleware)."""
|
"""Get current username from request state (set by auth middleware)."""
|
||||||
@@ -56,7 +58,17 @@ def _auth_disabled() -> bool:
|
|||||||
"""True when the operator has explicitly turned off auth via .env.
|
"""True when the operator has explicitly turned off auth via .env.
|
||||||
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
|
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
|
||||||
three call sites agree on what "off" means."""
|
three call sites agree on what "off" means."""
|
||||||
return os.getenv("AUTH_ENABLED", "true").lower() == "false"
|
return auth_disabled()
|
||||||
|
|
||||||
|
|
||||||
|
def storage_owner_for_request(request: Request) -> Optional[str]:
|
||||||
|
"""Resolve the storage owner for code paths that need an owner bucket.
|
||||||
|
|
||||||
|
This does not replace route authentication. It only gives auth-disabled
|
||||||
|
no-login mode a stable storage identity instead of writing new data as
|
||||||
|
legacy NULL/ownerless state.
|
||||||
|
"""
|
||||||
|
return effective_storage_owner(effective_user(request))
|
||||||
|
|
||||||
|
|
||||||
def require_user(request: Request) -> str:
|
def require_user(request: Request) -> str:
|
||||||
|
|||||||
+20
-9
@@ -15,6 +15,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src import bg_jobs
|
from src import bg_jobs
|
||||||
|
from src.prompt_security import untrusted_context_message
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,6 +26,16 @@ POLL_INTERVAL_S = 5
|
|||||||
_FOLLOWUP_MAX_ROUNDS = 12
|
_FOLLOWUP_MAX_ROUNDS = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _background_result_message(rec):
|
||||||
|
inject = (
|
||||||
|
f"[Background job {rec['id']} finished]\n\n"
|
||||||
|
f"{bg_jobs.result_text(rec)}\n\n"
|
||||||
|
"Continue the task using this output. Don't repeat work that's already done. "
|
||||||
|
"If the task is now complete, give the user the final result."
|
||||||
|
)
|
||||||
|
return untrusted_context_message("background job output", inject)
|
||||||
|
|
||||||
|
|
||||||
async def _drain_agent(sess, messages):
|
async def _drain_agent(sess, messages):
|
||||||
"""Run the agent loop headless against a session. Returns
|
"""Run the agent loop headless against a session. Returns
|
||||||
(final_prose, tool_events) — tool_events in the same shape the live chat
|
(final_prose, tool_events) — tool_events in the same shape the live chat
|
||||||
@@ -62,13 +73,19 @@ async def _drain_agent(sess, messages):
|
|||||||
round_num = d.get("round", round_num)
|
round_num = d.get("round", round_num)
|
||||||
elif d.get("type") == "tool_output":
|
elif d.get("type") == "tool_output":
|
||||||
# Mirror the live chat's tool_event shape (chat_routes / chatRenderer).
|
# Mirror the live chat's tool_event shape (chat_routes / chatRenderer).
|
||||||
tool_events.append({
|
tool_event = {
|
||||||
"round": round_num,
|
"round": round_num,
|
||||||
"tool": d.get("tool"),
|
"tool": d.get("tool"),
|
||||||
"command": d.get("command"),
|
"command": d.get("command"),
|
||||||
"output": d.get("output"),
|
"output": d.get("output"),
|
||||||
"exit_code": d.get("exit_code"),
|
"exit_code": d.get("exit_code"),
|
||||||
})
|
}
|
||||||
|
if isinstance(d.get("ask_user"), dict):
|
||||||
|
# Preserve exact-approval cards from a tainted background-job
|
||||||
|
# continuation so the user can authorize the sealed action on
|
||||||
|
# the next foreground turn instead of losing it headlessly.
|
||||||
|
tool_event["ask_user"] = d["ask_user"]
|
||||||
|
tool_events.append(tool_event)
|
||||||
return full, tool_events
|
return full, tool_events
|
||||||
|
|
||||||
|
|
||||||
@@ -101,14 +118,8 @@ async def _run_followup(rec: dict) -> bool:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
inject = (
|
|
||||||
f"[Background job {rec['id']} finished]\n\n"
|
|
||||||
f"{bg_jobs.result_text(rec)}\n\n"
|
|
||||||
"Continue the task using this output. Don't repeat work that's already done. "
|
|
||||||
"If the task is now complete, give the user the final result."
|
|
||||||
)
|
|
||||||
context = sess.get_context_messages()
|
context = sess.get_context_messages()
|
||||||
context.append({"role": "user", "content": inject})
|
context.append(_background_result_message(rec))
|
||||||
|
|
||||||
full, tool_events = await _drain_agent(sess, context)
|
full, tool_events = await _drain_agent(sess, context)
|
||||||
|
|
||||||
|
|||||||
+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
|
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
|
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:
|
try:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json as _json
|
import json as _json
|
||||||
|
from src.tool_security import owner_is_admin_or_single_user
|
||||||
research_dir = Path(DEEP_RESEARCH_DIR)
|
research_dir = Path(DEEP_RESEARCH_DIR)
|
||||||
if not research_dir.exists():
|
if not research_dir.exists():
|
||||||
raise TaskNoop("no research directory")
|
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"))
|
files = list(research_dir.glob("*.json"))
|
||||||
removed = []
|
removed = []
|
||||||
for p in files:
|
for p in files:
|
||||||
|
|||||||
@@ -381,7 +381,10 @@ class ChatProcessor:
|
|||||||
)
|
)
|
||||||
if len(rag_content) > 10000:
|
if len(rag_content) > 10000:
|
||||||
rag_content = rag_content[:10000] + "\n[Truncated]"
|
rag_content = rag_content[:10000] + "\n[Truncated]"
|
||||||
preface.append(untrusted_context_message("retrieved documents", rag_content))
|
preface.append(untrusted_context_message(
|
||||||
|
"retrieved documents",
|
||||||
|
rag_content,
|
||||||
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"RAG retrieval failed: {e}")
|
logger.warning(f"RAG retrieval failed: {e}")
|
||||||
|
|
||||||
@@ -465,6 +468,7 @@ class ChatProcessor:
|
|||||||
preface.append(untrusted_context_message(
|
preface.append(untrusted_context_message(
|
||||||
f"web page: {url}",
|
f"web page: {url}",
|
||||||
f"Content from {url}:\n\n{content}",
|
f"Content from {url}:\n\n{content}",
|
||||||
|
provenance_origin="external",
|
||||||
))
|
))
|
||||||
|
|
||||||
# Skills index — progressive disclosure. Only injected when the
|
# Skills index — progressive disclosure. Only injected when the
|
||||||
@@ -488,6 +492,9 @@ class ChatProcessor:
|
|||||||
for s in sorted(by_cat[cat], key=lambda x: x["name"]):
|
for s in sorted(by_cat[cat], key=lambda x: x["name"]):
|
||||||
desc = s.get("description") or ""
|
desc = s.get("description") or ""
|
||||||
lines.append(f" - {s['name']}: {desc}" if desc else f" - {s['name']}")
|
lines.append(f" - {s['name']}: {desc}" if desc else f" - {s['name']}")
|
||||||
preface.append(untrusted_context_message("available skills index", "\n".join(lines)))
|
preface.append(untrusted_context_message(
|
||||||
|
"available skills index",
|
||||||
|
"\n".join(lines),
|
||||||
|
))
|
||||||
|
|
||||||
return preface, rag_sources, web_sources
|
return preface, rag_sources, web_sources
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
|
|||||||
if essential_system:
|
if essential_system:
|
||||||
sys_text = essential_system[0].get("content", "")
|
sys_text = essential_system[0].get("content", "")
|
||||||
if len(sys_text) > 2000:
|
if len(sys_text) > 2000:
|
||||||
essential_system[0] = {"role": "system", "content": sys_text[:2000] + "\n[System prompt truncated for context limits]"}
|
truncated_system = dict(essential_system[0])
|
||||||
|
truncated_system["content"] = sys_text[:2000] + "\n[System prompt truncated for context limits]"
|
||||||
|
essential_system[0] = truncated_system
|
||||||
trimmed = essential_system + convo_msgs
|
trimmed = essential_system + convo_msgs
|
||||||
if estimate_tokens(trimmed) <= budget:
|
if estimate_tokens(trimmed) <= budget:
|
||||||
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
|
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
|
||||||
@@ -325,6 +327,9 @@ async def maybe_compact(
|
|||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
headers: Optional[Dict] = None,
|
headers: Optional[Dict] = None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
persist: bool = True,
|
||||||
|
compaction_state: Optional[Dict[str, Any]] = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Check context usage and compact if above threshold.
|
"""Check context usage and compact if above threshold.
|
||||||
|
|
||||||
@@ -416,7 +421,17 @@ async def maybe_compact(
|
|||||||
# offset — session.history INCLUDES the system messages, but
|
# offset — session.history INCLUDES the system messages, but
|
||||||
# split_point is indexed against convo_msgs which does NOT. Without
|
# split_point is indexed against convo_msgs which does NOT. Without
|
||||||
# this, the slice drops the leading system message(s).
|
# this, the slice drops the leading system message(s).
|
||||||
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
|
if compaction_state is not None:
|
||||||
|
compaction_state.update({
|
||||||
|
"split_point": split_point,
|
||||||
|
"summary": summary,
|
||||||
|
"system_msg_count": len(system_msgs),
|
||||||
|
"applied": False,
|
||||||
|
})
|
||||||
|
if persist:
|
||||||
|
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
|
||||||
|
if compaction_state is not None:
|
||||||
|
compaction_state["applied"] = True
|
||||||
|
|
||||||
new_used = estimate_tokens(compacted)
|
new_used = estimate_tokens(compacted)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -427,6 +442,51 @@ async def maybe_compact(
|
|||||||
return compacted, context_length, True
|
return compacted, context_length, True
|
||||||
|
|
||||||
|
|
||||||
|
def apply_compaction_state(session, compaction_state: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
"""Persist a route-specific compaction after that route commits output.
|
||||||
|
|
||||||
|
Candidate prompts may be compacted speculatively while an explicit
|
||||||
|
foreground fallback chain is being tried. Persisting at construction time
|
||||||
|
would let an unavailable route rewrite history before another route answers,
|
||||||
|
so callers hold this small plan and apply only the winning route's plan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
state = compaction_state if isinstance(compaction_state, dict) else None
|
||||||
|
if not state or state.get("applied"):
|
||||||
|
return False
|
||||||
|
summary = state.get("summary")
|
||||||
|
split_point = state.get("split_point")
|
||||||
|
system_msg_count = state.get("system_msg_count", 0)
|
||||||
|
if not isinstance(summary, str) or not isinstance(split_point, int):
|
||||||
|
return False
|
||||||
|
_update_session_history(
|
||||||
|
session,
|
||||||
|
split_point,
|
||||||
|
summary,
|
||||||
|
system_msg_count=system_msg_count if isinstance(system_msg_count, int) else 0,
|
||||||
|
)
|
||||||
|
state["applied"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def apply_compaction_state_for_session(
|
||||||
|
session_id: Optional[str],
|
||||||
|
compaction_state: Optional[Dict[str, Any]],
|
||||||
|
) -> bool:
|
||||||
|
"""Resolve an in-memory session and apply a deferred compaction plan."""
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from core.models import get_session_manager_instance
|
||||||
|
|
||||||
|
manager = get_session_manager_instance()
|
||||||
|
session = manager.get_session(session_id) if manager else None
|
||||||
|
except Exception:
|
||||||
|
session = None
|
||||||
|
return apply_compaction_state(session, compaction_state) if session else False
|
||||||
|
|
||||||
|
|
||||||
def _update_session_history(session, split_point: int, summary: str,
|
def _update_session_history(session, split_point: int, summary: str,
|
||||||
system_msg_count: int = 0):
|
system_msg_count: int = 0):
|
||||||
"""Update the in-memory session history after compaction.
|
"""Update the in-memory session history after compaction.
|
||||||
|
|||||||
+216
-20
@@ -5,6 +5,7 @@ Consolidates the 4+ copies of normalize_base / resolve_endpoint logic into one p
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -27,6 +28,43 @@ _NON_CHAT_MODEL = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def endpoint_cost_tracked(url: str, endpoint_kind: Optional[str] = None) -> bool:
|
||||||
|
"""Return whether token cost should be tracked for a concrete route.
|
||||||
|
|
||||||
|
This is intentionally a non-secret route classification. It mirrors the
|
||||||
|
frontend's local/subscription exclusions without exposing endpoint URLs to
|
||||||
|
message metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url or "")
|
||||||
|
host = (parsed.hostname or "").lower().rstrip(".")
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
if host == "chatgpt.com" and (
|
||||||
|
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
kind = str(endpoint_kind or "auto").strip().lower()
|
||||||
|
if kind == "local":
|
||||||
|
return False
|
||||||
|
if kind in {"api", "proxy"}:
|
||||||
|
return True
|
||||||
|
if host in {"localhost", "0.0.0.0", "host.docker.internal"} or host.endswith(".local"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
return ip.is_global
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if "." not in host:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _first_chat_model(models) -> Optional[str]:
|
def _first_chat_model(models) -> Optional[str]:
|
||||||
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
|
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
|
||||||
for m in (models or []):
|
for m in (models or []):
|
||||||
@@ -396,10 +434,14 @@ def resolve_endpoint(
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def resolve_endpoint_by_id(
|
def _resolve_endpoint_by_id_with_descriptor(
|
||||||
ep_id: str, model: Optional[str] = None, owner: Optional[str] = None
|
ep_id: str,
|
||||||
) -> Optional[Tuple[str, str, Dict]]:
|
model: Optional[str] = None,
|
||||||
"""Resolve a specific endpoint id (+ optional model) to (chat_url, model, headers).
|
owner: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
require_exact_model: bool = False,
|
||||||
|
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
|
||||||
|
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
|
||||||
|
|
||||||
Returns None if the endpoint doesn't exist or is disabled. Used to turn
|
Returns None if the endpoint doesn't exist or is disabled. Used to turn
|
||||||
a configured fallback entry ({endpoint_id, model}) into a dispatch target.
|
a configured fallback entry ({endpoint_id, model}) into a dispatch target.
|
||||||
@@ -426,15 +468,34 @@ def resolve_endpoint_by_id(
|
|||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
m = (model or "").strip()
|
m = (model or "").strip()
|
||||||
# Drop a model the user disabled on the endpoint, then pick the first
|
enabled_models = _endpoint_enabled_models(ep)
|
||||||
# enabled chat model rather than a hidden one.
|
if require_exact_model:
|
||||||
if m and m in _endpoint_hidden_models(ep):
|
# Explicit foreground fallback entries are concrete choices. A
|
||||||
m = ""
|
# hidden or known-missing model must disable the entry instead of
|
||||||
if not m:
|
# silently substituting another model from the endpoint.
|
||||||
m = _first_chat_model(_endpoint_enabled_models(ep)) or ""
|
if not m or m in _endpoint_hidden_models(ep):
|
||||||
|
return None
|
||||||
|
if enabled_models and m not in enabled_models:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# Legacy Utility/Vision chains retain their model-repair behavior.
|
||||||
|
if m and m in _endpoint_hidden_models(ep):
|
||||||
|
m = ""
|
||||||
|
if not m:
|
||||||
|
m = _first_chat_model(enabled_models) or ""
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
return chat_url, m, headers
|
return (
|
||||||
|
(chat_url, m, headers),
|
||||||
|
{
|
||||||
|
"endpoint_id": ep.id,
|
||||||
|
"endpoint_label": getattr(ep, "name", None) or ep.id,
|
||||||
|
"endpoint_cost_tracked": endpoint_cost_tracked(
|
||||||
|
chat_url,
|
||||||
|
getattr(ep, "endpoint_kind", None),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
|
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -442,11 +503,101 @@ def resolve_endpoint_by_id(
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
|
def resolve_endpoint_by_id(
|
||||||
"""Compatibility shim for the retired default-chat fallback chain."""
|
ep_id: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
require_exact_model: bool = False,
|
||||||
|
) -> Optional[Tuple[str, str, Dict]]:
|
||||||
|
"""Resolve a specific endpoint id (+ optional model) to its runtime route."""
|
||||||
|
|
||||||
del owner
|
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||||
return []
|
ep_id,
|
||||||
|
model,
|
||||||
|
owner=owner,
|
||||||
|
require_exact_model=require_exact_model,
|
||||||
|
)
|
||||||
|
return resolved[0] if resolved else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_route_descriptor(
|
||||||
|
endpoint_url: str,
|
||||||
|
model: str,
|
||||||
|
headers: Optional[Dict] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return the visible endpoint identity for an already-resolved route.
|
||||||
|
|
||||||
|
Headers are compared only inside the process so two endpoints using the
|
||||||
|
same provider URL/model but different credentials remain distinguishable.
|
||||||
|
No credential material is returned or logged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not endpoint_url or not model:
|
||||||
|
return {
|
||||||
|
"endpoint_id": None,
|
||||||
|
"endpoint_label": "Selected route",
|
||||||
|
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
|
||||||
|
}
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
|
if owner:
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
q = owner_filter(q, ModelEndpoint, owner)
|
||||||
|
expected = (endpoint_url.rstrip("/"), model, headers or {})
|
||||||
|
for ep in q.all():
|
||||||
|
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||||
|
ep.id,
|
||||||
|
model,
|
||||||
|
owner=owner,
|
||||||
|
require_exact_model=True,
|
||||||
|
)
|
||||||
|
if not resolved:
|
||||||
|
continue
|
||||||
|
candidate, descriptor = resolved
|
||||||
|
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
|
||||||
|
if actual == expected:
|
||||||
|
return descriptor
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not identify selected endpoint route: %s", e)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return {
|
||||||
|
"endpoint_id": None,
|
||||||
|
"endpoint_label": "Selected route",
|
||||||
|
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_route_descriptor_by_id(
|
||||||
|
endpoint_id: str,
|
||||||
|
endpoint_url: str,
|
||||||
|
model: str,
|
||||||
|
headers: Optional[Dict] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Resolve a selected route's identity without relying on row order.
|
||||||
|
|
||||||
|
The explicit endpoint id is still verified against the resolved runtime
|
||||||
|
route. This prevents stale or mismatched request metadata from being used
|
||||||
|
for attribution while disambiguating endpoints whose routes are otherwise
|
||||||
|
identical.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||||
|
endpoint_id,
|
||||||
|
model,
|
||||||
|
owner=owner,
|
||||||
|
require_exact_model=True,
|
||||||
|
)
|
||||||
|
if not resolved:
|
||||||
|
return None
|
||||||
|
candidate, descriptor = resolved
|
||||||
|
expected = ((endpoint_url or "").rstrip("/"), model, headers or {})
|
||||||
|
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
|
||||||
|
return descriptor if actual == expected else None
|
||||||
|
|
||||||
|
|
||||||
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
|
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||||
@@ -460,17 +611,62 @@ def resolve_vision_fallback_candidates(owner: Optional[str] = None) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
|
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
|
||||||
out = []
|
|
||||||
try:
|
try:
|
||||||
from src.settings import get_user_setting, load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
|
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
|
||||||
except Exception:
|
except Exception:
|
||||||
return out
|
return []
|
||||||
for entry in chain:
|
return resolve_fallback_entries(chain, owner=owner)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_fallback_entries(
|
||||||
|
entries,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
require_exact_model: bool = False,
|
||||||
|
) -> list:
|
||||||
|
"""Resolve ordered endpoint/model entries within the caller's owner scope."""
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for entry in entries or []:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
resolved = resolve_endpoint_by_id(entry.get("endpoint_id", ""), entry.get("model", ""), owner=owner)
|
resolved = resolve_endpoint_by_id(
|
||||||
if resolved:
|
entry.get("endpoint_id", ""),
|
||||||
|
entry.get("model", ""),
|
||||||
|
owner=owner,
|
||||||
|
require_exact_model=require_exact_model,
|
||||||
|
)
|
||||||
|
if resolved and resolved not in out:
|
||||||
out.append(resolved)
|
out.append(resolved)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_fallback_entries_with_descriptors(
|
||||||
|
entries,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
require_exact_model: bool = False,
|
||||||
|
) -> list:
|
||||||
|
"""Resolve ordered entries while retaining safe endpoint provenance."""
|
||||||
|
|
||||||
|
out = []
|
||||||
|
seen = []
|
||||||
|
for entry in entries or []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||||
|
entry.get("endpoint_id", ""),
|
||||||
|
entry.get("model", ""),
|
||||||
|
owner=owner,
|
||||||
|
require_exact_model=require_exact_model,
|
||||||
|
)
|
||||||
|
if not resolved:
|
||||||
|
continue
|
||||||
|
candidate, descriptor = resolved
|
||||||
|
if any(candidate == prior for prior in seen):
|
||||||
|
continue
|
||||||
|
seen.append(candidate)
|
||||||
|
out.append((candidate, descriptor))
|
||||||
|
return out
|
||||||
|
|||||||
+189
-14
@@ -1,22 +1,155 @@
|
|||||||
"""Foreground Chat and Agent model-routing policy.
|
"""Explicit foreground Chat and Agent model-routing policy."""
|
||||||
|
|
||||||
The selected session model is strict by default. Historical
|
from dataclasses import dataclass
|
||||||
``default_model_fallbacks`` values remain stored for compatibility, but this
|
from typing import Any, Collection, Dict, FrozenSet, Optional, Tuple
|
||||||
policy intentionally does not read or migrate them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from src.endpoint_resolver import (
|
||||||
|
endpoint_cost_tracked,
|
||||||
|
resolve_fallback_entries,
|
||||||
|
resolve_fallback_entries_with_descriptors,
|
||||||
|
resolve_route_descriptor,
|
||||||
|
resolve_route_descriptor_by_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
_DEFAULT_FALLBACK_ENTRY_RESOLVER = resolve_fallback_entries
|
||||||
|
|
||||||
|
|
||||||
|
FOREGROUND_FALLBACK_ENABLED_KEY = "foreground_fallback_enabled"
|
||||||
|
FOREGROUND_FALLBACK_LIST_KEY = "foreground_model_fallbacks"
|
||||||
|
FOREGROUND_AVAILABILITY_STATUSES: FrozenSet[int] = frozenset({
|
||||||
|
408, 425, 429, 500, 502, 503, 504, 507, 508, 529,
|
||||||
|
})
|
||||||
|
MAX_FOREGROUND_FALLBACKS = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ForegroundModelPolicy:
|
||||||
|
"""Resolved per-user foreground fallback policy."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
fallback_candidates: Tuple[tuple, ...] = ()
|
||||||
|
fallback_descriptors: Tuple[dict, ...] = ()
|
||||||
|
eligible_statuses: FrozenSet[int] = FOREGROUND_AVAILABILITY_STATUSES
|
||||||
|
fallback_on_empty: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_policy_preferences(owner: Optional[str]) -> dict:
|
||||||
|
"""Load only preferences that explicitly belong to ``owner``.
|
||||||
|
|
||||||
|
The generic preferences loader intentionally treats a legacy flat store as
|
||||||
|
the single-user preferences object. That compatibility must not cross an
|
||||||
|
authentication transition: once a named owner is present, foreground
|
||||||
|
fallback consent exists only in an actual ``_users[owner]`` dictionary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from routes import prefs_routes
|
||||||
|
|
||||||
|
if owner is None:
|
||||||
|
prefs = prefs_routes._load_for_user(None)
|
||||||
|
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||||
|
|
||||||
|
raw = prefs_routes._load()
|
||||||
|
users = raw.get("_users") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(users, dict):
|
||||||
|
return {}
|
||||||
|
prefs = users.get(owner)
|
||||||
|
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_foreground_model_policy(
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
allowed_models: Optional[Collection[str]] = None,
|
||||||
|
) -> ForegroundModelPolicy:
|
||||||
|
"""Resolve an explicit owner-scoped policy, failing closed to strict mode.
|
||||||
|
|
||||||
|
The policy is stored in user preferences even when authentication is
|
||||||
|
disabled. Historical ``default_model_fallbacks`` values are deliberately
|
||||||
|
unrelated and are never read or migrated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
prefs = _load_policy_preferences(owner)
|
||||||
|
except Exception:
|
||||||
|
return ForegroundModelPolicy()
|
||||||
|
|
||||||
|
if prefs.get(FOREGROUND_FALLBACK_ENABLED_KEY) is not True:
|
||||||
|
return ForegroundModelPolicy()
|
||||||
|
|
||||||
|
entries = prefs.get(FOREGROUND_FALLBACK_LIST_KEY)
|
||||||
|
if not isinstance(entries, list) or not entries:
|
||||||
|
return ForegroundModelPolicy()
|
||||||
|
if allowed_models is not None:
|
||||||
|
allowed = frozenset(allowed_models)
|
||||||
|
entries = [
|
||||||
|
entry for entry in entries
|
||||||
|
if (
|
||||||
|
isinstance(entry, dict)
|
||||||
|
and isinstance(entry.get("model"), str)
|
||||||
|
and entry.get("model") in allowed
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not entries:
|
||||||
|
return ForegroundModelPolicy()
|
||||||
|
entries = entries[:MAX_FOREGROUND_FALLBACKS]
|
||||||
|
|
||||||
|
if resolve_fallback_entries is not _DEFAULT_FALLBACK_ENTRY_RESOLVER:
|
||||||
|
# Preserve the long-standing resolver seam used by downstream tests and
|
||||||
|
# integrations. Production uses the descriptor-aware resolver below.
|
||||||
|
compatibility_candidates = resolve_fallback_entries(
|
||||||
|
entries,
|
||||||
|
owner=owner,
|
||||||
|
require_exact_model=True,
|
||||||
|
)
|
||||||
|
# Known limitation of this test-only seam: alignment matches on model
|
||||||
|
# alone, so when two entries share a model and the resolver skips the
|
||||||
|
# first, the surviving candidate inherits the skipped entry's
|
||||||
|
# endpoint_id. Production uses the descriptor-aware branch below,
|
||||||
|
# which is unaffected.
|
||||||
|
resolved_routes = []
|
||||||
|
remaining_entries = list(entries)
|
||||||
|
for candidate in compatibility_candidates:
|
||||||
|
matching_index = next(
|
||||||
|
(
|
||||||
|
index for index, entry in enumerate(remaining_entries)
|
||||||
|
if isinstance(entry, dict)
|
||||||
|
and entry.get("model") == candidate[1]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
matching_entry = (
|
||||||
|
remaining_entries.pop(matching_index)
|
||||||
|
if matching_index is not None
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
descriptor = {
|
||||||
|
"endpoint_id": matching_entry.get("endpoint_id"),
|
||||||
|
"endpoint_label": matching_entry.get("endpoint_id") or "Fallback route",
|
||||||
|
"endpoint_cost_tracked": endpoint_cost_tracked(candidate[0]),
|
||||||
|
}
|
||||||
|
resolved_routes.append((candidate, descriptor))
|
||||||
|
else:
|
||||||
|
resolved_routes = resolve_fallback_entries_with_descriptors(
|
||||||
|
entries,
|
||||||
|
owner=owner,
|
||||||
|
require_exact_model=True,
|
||||||
|
)
|
||||||
|
candidates = [candidate for candidate, _descriptor in resolved_routes]
|
||||||
|
if not candidates:
|
||||||
|
return ForegroundModelPolicy()
|
||||||
|
|
||||||
|
return ForegroundModelPolicy(
|
||||||
|
enabled=True,
|
||||||
|
fallback_candidates=tuple(candidates),
|
||||||
|
fallback_descriptors=tuple(
|
||||||
|
dict(descriptor) for _candidate, descriptor in resolved_routes
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
|
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||||
"""Return fallback candidates for a foreground Chat or Agent request.
|
"""Return only candidates explicitly enabled by the current user."""
|
||||||
|
|
||||||
Foreground routing is strict, so no alternate endpoint/model is eligible.
|
return list(resolve_foreground_model_policy(owner).fallback_candidates)
|
||||||
``owner`` is accepted to keep this policy boundary owner-aware.
|
|
||||||
"""
|
|
||||||
|
|
||||||
del owner
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def build_foreground_model_candidates(
|
def build_foreground_model_candidates(
|
||||||
@@ -24,8 +157,50 @@ def build_foreground_model_candidates(
|
|||||||
model: str,
|
model: str,
|
||||||
headers: Optional[Dict[str, Any]] = None,
|
headers: Optional[Dict[str, Any]] = None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
|
policy: Optional[ForegroundModelPolicy] = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Build the ordered candidate list for a foreground request."""
|
"""Build the ordered candidate list for a foreground request."""
|
||||||
|
|
||||||
|
policy = policy or resolve_foreground_model_policy(owner)
|
||||||
primary = (endpoint_url, model, headers or {})
|
primary = (endpoint_url, model, headers or {})
|
||||||
return [primary] + resolve_foreground_fallback_candidates(owner=owner)
|
candidates = [primary]
|
||||||
|
for candidate in policy.fallback_candidates:
|
||||||
|
if candidate not in candidates:
|
||||||
|
candidates.append(candidate)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def build_foreground_route_descriptors(
|
||||||
|
endpoint_url: str,
|
||||||
|
model: str,
|
||||||
|
headers: Optional[Dict[str, Any]] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
policy: Optional[ForegroundModelPolicy] = None,
|
||||||
|
selected_endpoint_id: Optional[str] = None,
|
||||||
|
) -> list:
|
||||||
|
"""Build safe route metadata parallel to foreground candidates."""
|
||||||
|
|
||||||
|
policy = policy or resolve_foreground_model_policy(owner)
|
||||||
|
selected = None
|
||||||
|
if selected_endpoint_id:
|
||||||
|
selected = resolve_route_descriptor_by_id(
|
||||||
|
selected_endpoint_id,
|
||||||
|
endpoint_url,
|
||||||
|
model,
|
||||||
|
headers or {},
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if selected is None:
|
||||||
|
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
|
||||||
|
primary = (endpoint_url, model, headers or {})
|
||||||
|
candidates = [primary]
|
||||||
|
descriptors = [selected]
|
||||||
|
for candidate, descriptor in zip(
|
||||||
|
policy.fallback_candidates,
|
||||||
|
policy.fallback_descriptors,
|
||||||
|
):
|
||||||
|
if candidate in candidates:
|
||||||
|
continue
|
||||||
|
candidates.append(candidate)
|
||||||
|
descriptors.append(dict(descriptor))
|
||||||
|
return descriptors
|
||||||
|
|||||||
+8
-1
@@ -719,7 +719,14 @@ async def execute_api_call(
|
|||||||
output = f"HTTP {status}\n{formatted}"
|
output = f"HTTP {status}\n{formatted}"
|
||||||
|
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
return {"error": output, "exit_code": 1}
|
return {
|
||||||
|
"error": output,
|
||||||
|
"exit_code": 1,
|
||||||
|
# The error string includes the remote response body. Preserve
|
||||||
|
# it for diagnostics, but make its provenance explicit so the
|
||||||
|
# agent gate does not treat HTTP failure as content-free.
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
|
|
||||||
return {"output": output, "exit_code": 0}
|
return {"output": output, "exit_code": 0}
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ _PASSIVE_EXACT_PATHS = {
|
|||||||
"/api/activity/heartbeat",
|
"/api/activity/heartbeat",
|
||||||
"/api/client-perf",
|
"/api/client-perf",
|
||||||
"/api/tasks/notifications",
|
"/api/tasks/notifications",
|
||||||
|
"/api/tasks/runs/recent",
|
||||||
"/api/research/active",
|
"/api/research/active",
|
||||||
"/api/email/urgency-state",
|
"/api/email/urgency-state",
|
||||||
|
# UI idle poll sibling of urgency-state; must not pre-empt background tasks.
|
||||||
|
"/api/email/unread-state",
|
||||||
}
|
}
|
||||||
|
|
||||||
_PASSIVE_PREFIXES = (
|
_PASSIVE_PREFIXES = (
|
||||||
@@ -74,6 +77,19 @@ _PASSIVE_PREFIXES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_stop_background_tasks_for_heartbeat(stop_background) -> bool:
|
||||||
|
"""Stop background work for browser activity only when the gate is enabled.
|
||||||
|
|
||||||
|
``stop_background`` is injected by the application boundary so this policy
|
||||||
|
remains independently testable without importing the full FastAPI app.
|
||||||
|
"""
|
||||||
|
if not _enabled():
|
||||||
|
return False
|
||||||
|
|
||||||
|
await stop_background(reason="browser heartbeat")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def should_track_interactive_request(path: str, method: str = "GET") -> bool:
|
def should_track_interactive_request(path: str, method: str = "GET") -> bool:
|
||||||
if not _enabled():
|
if not _enabled():
|
||||||
return False
|
return False
|
||||||
|
|||||||
+883
-124
File diff suppressed because it is too large
Load Diff
@@ -530,6 +530,8 @@ class McpManager:
|
|||||||
"stderr": output if is_error else "",
|
"stderr": output if is_error else "",
|
||||||
"exit_code": 1 if is_error else 0,
|
"exit_code": 1 if is_error else 0,
|
||||||
}
|
}
|
||||||
|
if is_error and output:
|
||||||
|
result_dict["untrusted_content"] = True
|
||||||
if images:
|
if images:
|
||||||
result_dict["images"] = images
|
result_dict["images"] = images
|
||||||
return result_dict
|
return result_dict
|
||||||
|
|||||||
+24
-10
@@ -15,18 +15,32 @@ from urllib.parse import urlparse, parse_qs
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# OAuth redirect URI registered with every authorization server via DCR. Loopback
|
||||||
# is allowed for native/desktop clients (RFC 8252); remote users finish via the
|
# is allowed for native/desktop clients (RFC 8252); remote users finish via the
|
||||||
# paste-back flow. Deployments not reachable at http://localhost:7000 (custom
|
# paste-back flow. Deployments whose externally reachable origin differs from the
|
||||||
# port, reverse proxy, or public domain) must set OAUTH_REDIRECT_BASE_URL (or
|
# port Odysseus binds — reverse proxy, public domain, or Docker, whose host port
|
||||||
# APP_PUBLIC_URL) to their externally reachable origin so the redirect lands back
|
# map is invisible inside the container — must set OAUTH_REDIRECT_BASE_URL (or
|
||||||
# on Odysseus. APP_PORT is intentionally not used: it is only the Docker host
|
# APP_PUBLIC_URL), otherwise the redirect never lands back on Odysseus.
|
||||||
# port-map; the app always listens on 7000 inside the container.
|
_REDIRECT_BASE = _resolve_redirect_base()
|
||||||
_REDIRECT_BASE = (
|
|
||||||
os.environ.get("OAUTH_REDIRECT_BASE_URL")
|
|
||||||
or os.environ.get("APP_PUBLIC_URL")
|
|
||||||
or "http://localhost:7000"
|
|
||||||
).rstrip("/")
|
|
||||||
REDIRECT_URI = f"{_REDIRECT_BASE}/api/mcp/oauth/callback"
|
REDIRECT_URI = f"{_REDIRECT_BASE}/api/mcp/oauth/callback"
|
||||||
|
|
||||||
# How long the background connect waits for the user to authorize before giving up.
|
# How long the background connect waits for the user to authorize before giving up.
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Shared owner identity constants and helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LOCAL_OWNER = "__odysseus_local__"
|
||||||
|
DEFAULT_LOCAL_OWNER_LABEL = "Local"
|
||||||
|
INTERNAL_TOOL_USER = "internal-tool"
|
||||||
|
|
||||||
|
REQUEST_SENTINEL_OWNERS = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
|
||||||
|
RESERVED_AUTH_USERNAMES = REQUEST_SENTINEL_OWNERS | {DEFAULT_LOCAL_OWNER}
|
||||||
|
|
||||||
|
|
||||||
|
def auth_disabled() -> bool:
|
||||||
|
"""Return True only when auth is explicitly disabled by configuration."""
|
||||||
|
return os.getenv("AUTH_ENABLED", "true").strip().lower() == "false"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_owner(owner: str | None) -> Optional[str]:
|
||||||
|
"""Normalize an owner-like value without inventing a fallback identity."""
|
||||||
|
value = str(owner or "").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def owner_key(owner: str | None) -> Optional[str]:
|
||||||
|
normalized = normalize_owner(owner)
|
||||||
|
return normalized.lower() if normalized else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_request_sentinel_owner(owner: str | None) -> bool:
|
||||||
|
return owner_key(owner) in REQUEST_SENTINEL_OWNERS
|
||||||
|
|
||||||
|
|
||||||
|
def effective_storage_owner(owner: str | None, *, auth_is_disabled: bool | None = None) -> Optional[str]:
|
||||||
|
"""Resolve the owner used for storage writes that need a real bucket.
|
||||||
|
|
||||||
|
``None`` still means no authenticated owner when auth is enabled. In the
|
||||||
|
explicit no-login mode, it resolves to the reserved local owner instead of
|
||||||
|
conflating local-operator writes with legacy NULL/ownerless rows.
|
||||||
|
"""
|
||||||
|
normalized = normalize_owner(owner)
|
||||||
|
if normalized:
|
||||||
|
if is_request_sentinel_owner(normalized):
|
||||||
|
return None
|
||||||
|
return normalized
|
||||||
|
disabled = auth_disabled() if auth_is_disabled is None else auth_is_disabled
|
||||||
|
if disabled:
|
||||||
|
return DEFAULT_LOCAL_OWNER
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_default_local_owner(owner: str | None) -> bool:
|
||||||
|
return owner_key(owner) == DEFAULT_LOCAL_OWNER
|
||||||
+15
-2
@@ -61,7 +61,13 @@ def _sanitize_label(label: str) -> str:
|
|||||||
return label
|
return label
|
||||||
|
|
||||||
|
|
||||||
def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
|
def untrusted_context_message(
|
||||||
|
label: str,
|
||||||
|
content: Any,
|
||||||
|
*,
|
||||||
|
provenance_origin: str | None = None,
|
||||||
|
arm_tool_gate: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Return an LLM message that keeps retrieved/source text out of system role.
|
"""Return an LLM message that keeps retrieved/source text out of system role.
|
||||||
|
|
||||||
The template is structured so that *only* the hardcoded
|
The template is structured so that *only* the hardcoded
|
||||||
@@ -73,6 +79,13 @@ def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
|
|||||||
safe_label = _sanitize_label(label)
|
safe_label = _sanitize_label(label)
|
||||||
text = "" if content is None else str(content)
|
text = "" if content is None else str(content)
|
||||||
text = _escape_guard_markers(text)
|
text = _escape_guard_markers(text)
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"trusted": False,
|
||||||
|
"source": label,
|
||||||
|
"tool_gate_untrusted": bool(arm_tool_gate),
|
||||||
|
}
|
||||||
|
if provenance_origin:
|
||||||
|
metadata["provenance_origin"] = provenance_origin
|
||||||
return {
|
return {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": (
|
"content": (
|
||||||
@@ -82,5 +95,5 @@ def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
|
|||||||
f"{text}\n"
|
f"{text}\n"
|
||||||
f"{GUARD_CLOSE}"
|
f"{GUARD_CLOSE}"
|
||||||
),
|
),
|
||||||
"metadata": {"trusted": False, "source": label},
|
"metadata": metadata,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
|
|||||||
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
|
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
|
||||||
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
|
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
|
||||||
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
|
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
|
||||||
|
selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID")
|
||||||
|
|
||||||
@field_validator('message')
|
@field_validator('message')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+19
-1
@@ -14,6 +14,13 @@ from src.constants import SETTINGS_FILE, FEATURES_FILE
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Keys retained in the raw settings store for compatibility and rollback, but
|
||||||
|
# deliberately unavailable through generic settings APIs or agent tools. They
|
||||||
|
# must stay in ``DEFAULT_SETTINGS`` so old files continue to load without data
|
||||||
|
# loss; callers that present or mutate settings should use this set as a
|
||||||
|
# tombstone boundary.
|
||||||
|
RETIRED_SETTING_KEYS = frozenset({"default_model_fallbacks"})
|
||||||
|
|
||||||
# Tiny TTL cache for settings/features. get_setting() is called on hot paths
|
# Tiny TTL cache for settings/features. get_setting() is called on hot paths
|
||||||
# (every chat, every preprocess); without this it re-parses the JSON each call.
|
# (every chat, every preprocess); without this it re-parses the JSON each call.
|
||||||
# Picks up edits within _CACHE_TTL seconds, which is fine for human-edited config.
|
# Picks up edits within _CACHE_TTL seconds, which is fine for human-edited config.
|
||||||
@@ -197,6 +204,17 @@ DEFAULT_SETTINGS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def without_retired_settings(settings: dict) -> dict:
|
||||||
|
"""Return a shallow copy suitable for generic settings interfaces."""
|
||||||
|
if not isinstance(settings, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in settings.items()
|
||||||
|
if key not in RETIRED_SETTING_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_FEATURES = {
|
DEFAULT_FEATURES = {
|
||||||
"web_search": True,
|
"web_search": True,
|
||||||
"web_fetch": True,
|
"web_fetch": True,
|
||||||
@@ -269,7 +287,7 @@ _PER_USER_KEYS = {
|
|||||||
# Default chat endpoint / model — without per-user resolution every new
|
# Default chat endpoint / model — without per-user resolution every new
|
||||||
# account inherited whatever the most-recent admin picked, which then
|
# account inherited whatever the most-recent admin picked, which then
|
||||||
# got injected into the chat composer on first open.
|
# got injected into the chat composer on first open.
|
||||||
"default_endpoint_id", "default_model", "default_model_fallbacks",
|
"default_endpoint_id", "default_model",
|
||||||
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
||||||
"research_endpoint_id", "research_model",
|
"research_endpoint_id", "research_model",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Shared resolver for background-task AI endpoints."""
|
"""Shared resolver for background-task AI endpoints."""
|
||||||
|
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_chat_fallback_candidates,
|
|
||||||
resolve_endpoint,
|
resolve_endpoint,
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
)
|
)
|
||||||
@@ -32,7 +31,6 @@ def resolve_task_candidates(
|
|||||||
2. Utility endpoint/model
|
2. Utility endpoint/model
|
||||||
3. Default endpoint/model
|
3. Default endpoint/model
|
||||||
4. Utility fallback chain
|
4. Utility fallback chain
|
||||||
5. Retired default-fallback compatibility hook (currently empty)
|
|
||||||
"""
|
"""
|
||||||
candidates = []
|
candidates = []
|
||||||
|
|
||||||
@@ -49,9 +47,6 @@ def resolve_task_candidates(
|
|||||||
_append(*resolve_endpoint("default", owner=owner))
|
_append(*resolve_endpoint("default", owner=owner))
|
||||||
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
|
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
|
||||||
_append(url, model, headers)
|
_append(url, model, headers)
|
||||||
for url, model, headers in resolve_chat_fallback_candidates(owner=owner):
|
|
||||||
_append(url, model, headers)
|
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||||
|
|
||||||
from core.auth import RESERVED_USERNAMES
|
from core.auth import RESERVED_USERNAMES
|
||||||
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.task_action_policy import (
|
from src.task_action_policy import (
|
||||||
is_admin_only_task_action,
|
is_admin_only_task_action,
|
||||||
owner_has_admin_task_privileges,
|
owner_has_admin_task_privileges,
|
||||||
@@ -1883,6 +1884,7 @@ class TaskScheduler:
|
|||||||
pass
|
pass
|
||||||
full_text = ""
|
full_text = ""
|
||||||
tool_results = []
|
tool_results = []
|
||||||
|
approval_pause = None
|
||||||
|
|
||||||
# Honor per-task max_steps (defense against runaway agent loops).
|
# Honor per-task max_steps (defense against runaway agent loops).
|
||||||
# Falls back to 20 if not set — the historical default.
|
# Falls back to 20 if not set — the historical default.
|
||||||
@@ -1929,9 +1931,44 @@ class TaskScheduler:
|
|||||||
tool_summary = data.get("stdout") or data.get("output") or data.get("result") or ""
|
tool_summary = data.get("stdout") or data.get("output") or data.get("result") or ""
|
||||||
if isinstance(tool_summary, str) and tool_summary.strip():
|
if isinstance(tool_summary, str) and tool_summary.strip():
|
||||||
tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}")
|
tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}")
|
||||||
|
approval = data.get("ask_user")
|
||||||
|
if (
|
||||||
|
isinstance(approval, dict)
|
||||||
|
and approval.get("kind") == "tool_approval"
|
||||||
|
):
|
||||||
|
approval_pause = {
|
||||||
|
"tool": data.get("tool") or "tool",
|
||||||
|
"approval_id": approval.get("approval_id"),
|
||||||
|
}
|
||||||
|
# Scheduled tasks have no interactive surface that
|
||||||
|
# can safely resume a one-use grant. Retire the
|
||||||
|
# record immediately instead of leaving it pending
|
||||||
|
# and report an explicit manual-action boundary.
|
||||||
|
try:
|
||||||
|
from src.tool_approvals import tool_approval_store
|
||||||
|
tool_approval_store.consume(
|
||||||
|
approval_pause["approval_id"],
|
||||||
|
decision="deny",
|
||||||
|
owner=task.owner,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"Could not retire scheduled-task approval",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
break
|
||||||
except (json.JSONDecodeError, KeyError):
|
except (json.JSONDecodeError, KeyError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if approval_pause is not None:
|
||||||
|
return (
|
||||||
|
"Scheduled task paused safely: "
|
||||||
|
f"{approval_pause['tool']} requested an exact action after "
|
||||||
|
"untrusted context. That action was not executed. Run this task "
|
||||||
|
"interactively to inspect and approve the action."
|
||||||
|
)
|
||||||
|
|
||||||
# Grace summarization — if the model exhausted rounds on tool calls
|
# Grace summarization — if the model exhausted rounds on tool calls
|
||||||
# without producing a final text response, do one last LLM call
|
# without producing a final text response, do one last LLM call
|
||||||
# asking it to summarize what it did. Guarantees output.
|
# asking it to summarize what it did. Guarantees output.
|
||||||
@@ -2484,7 +2521,7 @@ class TaskScheduler:
|
|||||||
# check-ins seeded, which then double-fire alongside the human user's
|
# check-ins seeded, which then double-fire alongside the human user's
|
||||||
# check-ins. This was the root cause of the duplicate 'Morning check-in'
|
# check-ins. This was the root cause of the duplicate 'Morning check-in'
|
||||||
# rows we had to manually clean up.
|
# rows we had to manually clean up.
|
||||||
if not owner or owner in RESERVED_USERNAMES:
|
if not owner or owner in REQUEST_SENTINEL_OWNERS:
|
||||||
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
|
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
|
||||||
return
|
return
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
|
|||||||
+110
-74
@@ -439,56 +439,11 @@ async def escalate_and_learn(
|
|||||||
failure_reason: str,
|
failure_reason: str,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Call the teacher, evaluate ITS attempt, save a skill on success.
|
"""Retire legacy background learning when no approval UI is available."""
|
||||||
|
logger.info(
|
||||||
Returns the saved skill name (or None if the teacher couldn't
|
"background teacher learning skipped: generated skills require an "
|
||||||
write one). Logs but doesn't raise — escalation is best-effort.
|
"interactive exact approval"
|
||||||
"""
|
|
||||||
from src.settings import get_setting
|
|
||||||
teacher_spec = (get_setting("teacher_model", "") or "").strip()
|
|
||||||
if not teacher_spec:
|
|
||||||
return None
|
|
||||||
|
|
||||||
prompt = _TEACHER_ESCALATION_PROMPT.format(
|
|
||||||
user_request=user_request or "(no user request captured)",
|
|
||||||
failure_reason=failure_reason or "(failure reason not captured)",
|
|
||||||
untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD,
|
|
||||||
trace=_format_trace(tool_results, agent_reply),
|
|
||||||
)
|
)
|
||||||
response = await _call_teacher(teacher_spec, prompt, owner=owner)
|
|
||||||
if not response:
|
|
||||||
return None
|
|
||||||
|
|
||||||
skill = _extract_skill_json(response)
|
|
||||||
if not skill:
|
|
||||||
# Teacher chose not to write a skill — see prompt contract.
|
|
||||||
logger.info("teacher declined to write a skill for this failure")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Same regex eval applied to the teacher's response — if the
|
|
||||||
# teacher itself sounded uncertain ("I don't have a tool"), drop
|
|
||||||
# the skill rather than persist a sketchy one.
|
|
||||||
status, reason = evaluate_turn_regex([], response)
|
|
||||||
if status == "failure":
|
|
||||||
logger.info(f"teacher response failed eval, skipping skill save: {reason}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Tag the skill with the escalation source for auditability.
|
|
||||||
skill.setdefault("source", "teacher-escalation")
|
|
||||||
skill.setdefault("teacher_model", teacher_spec)
|
|
||||||
# Force action=add regardless of what the teacher wrote.
|
|
||||||
skill["action"] = "add"
|
|
||||||
|
|
||||||
import json
|
|
||||||
from src.tool_implementations import do_manage_skills
|
|
||||||
try:
|
|
||||||
result = await do_manage_skills(json.dumps(skill), owner=owner)
|
|
||||||
if isinstance(result, dict) and not result.get("error"):
|
|
||||||
logger.info(f"teacher wrote skill: {skill.get('name')}")
|
|
||||||
return skill.get("name")
|
|
||||||
logger.warning(f"skill save failed: {result}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"skill save raised: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -563,6 +518,12 @@ async def run_teacher_inline(
|
|||||||
student_tool_events: List[Dict[str, Any]],
|
student_tool_events: List[Dict[str, Any]],
|
||||||
student_reply: str,
|
student_reply: str,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
workspace: Optional[str] = None,
|
||||||
|
disabled_tools: Optional[set[str]] = None,
|
||||||
|
tool_policy: Any = None,
|
||||||
|
active_document: Any = None,
|
||||||
|
active_email: Optional[Dict[str, str]] = None,
|
||||||
):
|
):
|
||||||
"""Async generator. Yields SSE event strings.
|
"""Async generator. Yields SSE event strings.
|
||||||
|
|
||||||
@@ -661,6 +622,7 @@ async def run_teacher_inline(
|
|||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
captured_tool_events: List[Dict[str, Any]] = []
|
captured_tool_events: List[Dict[str, Any]] = []
|
||||||
captured_text_parts: List[str] = []
|
captured_text_parts: List[str] = []
|
||||||
|
captured_metrics: Dict[str, Any] = {}
|
||||||
|
|
||||||
async for evt_str in stream_agent_loop(
|
async for evt_str in stream_agent_loop(
|
||||||
endpoint_url=teacher_url,
|
endpoint_url=teacher_url,
|
||||||
@@ -668,6 +630,12 @@ async def run_teacher_inline(
|
|||||||
messages=teacher_messages,
|
messages=teacher_messages,
|
||||||
headers=teacher_headers,
|
headers=teacher_headers,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
session_id=session_id,
|
||||||
|
workspace=workspace,
|
||||||
|
disabled_tools=disabled_tools,
|
||||||
|
tool_policy=tool_policy,
|
||||||
|
active_document=active_document,
|
||||||
|
active_email=active_email,
|
||||||
_is_teacher_run=True,
|
_is_teacher_run=True,
|
||||||
):
|
):
|
||||||
# Swallow teacher's own [DONE] — outer loop emits the real one
|
# Swallow teacher's own [DONE] — outer loop emits the real one
|
||||||
@@ -682,13 +650,21 @@ async def run_teacher_inline(
|
|||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
payload["teacher"] = True
|
payload["teacher"] = True
|
||||||
typ = payload.get("type")
|
typ = payload.get("type")
|
||||||
|
if typ == "metrics" and isinstance(payload.get("data"), dict):
|
||||||
|
# The outer chat route persists only the last metrics
|
||||||
|
# payload. Keep a copy so any approval produced after the
|
||||||
|
# recursive teacher run's metrics remains reloadable.
|
||||||
|
captured_metrics = dict(payload["data"])
|
||||||
if typ == "tool_output":
|
if typ == "tool_output":
|
||||||
captured_tool_events.append({
|
captured_tool_event = {
|
||||||
"tool": payload.get("tool"),
|
"tool": payload.get("tool"),
|
||||||
"command": payload.get("command"),
|
"command": payload.get("command"),
|
||||||
"output": payload.get("output"),
|
"output": payload.get("output"),
|
||||||
"exit_code": payload.get("exit_code"),
|
"exit_code": payload.get("exit_code"),
|
||||||
})
|
}
|
||||||
|
if isinstance(payload.get("ask_user"), dict):
|
||||||
|
captured_tool_event["ask_user"] = payload["ask_user"]
|
||||||
|
captured_tool_events.append(captured_tool_event)
|
||||||
if "delta" in payload and isinstance(payload["delta"], str):
|
if "delta" in payload and isinstance(payload["delta"], str):
|
||||||
if payload.get("thinking"):
|
if payload.get("thinking"):
|
||||||
continue
|
continue
|
||||||
@@ -697,6 +673,12 @@ async def run_teacher_inline(
|
|||||||
continue
|
continue
|
||||||
yield evt_str
|
yield evt_str
|
||||||
|
|
||||||
|
# A takeover that paused for a question or exact action has not completed
|
||||||
|
# yet. Its server-owned approval card is already in the live/persisted tool
|
||||||
|
# events; do not evaluate the partial trace or distill it into a skill.
|
||||||
|
if any(event.get("ask_user") for event in captured_tool_events):
|
||||||
|
return
|
||||||
|
|
||||||
teacher_text = "".join(captured_text_parts).strip()
|
teacher_text = "".join(captured_text_parts).strip()
|
||||||
t_status, t_reason = evaluate_turn_regex(captured_tool_events, teacher_text)
|
t_status, t_reason = evaluate_turn_regex(captured_tool_events, teacher_text)
|
||||||
if t_status == "failure":
|
if t_status == "failure":
|
||||||
@@ -740,31 +722,85 @@ async def run_teacher_inline(
|
|||||||
skill.setdefault("source", "teacher-escalation")
|
skill.setdefault("source", "teacher-escalation")
|
||||||
skill.setdefault("teacher_model", teacher_spec)
|
skill.setdefault("teacher_model", teacher_spec)
|
||||||
|
|
||||||
import json as _json
|
if not session_id:
|
||||||
from src.tool_implementations import do_manage_skills
|
|
||||||
try:
|
|
||||||
result = await do_manage_skills(_json.dumps(skill), owner=owner)
|
|
||||||
if isinstance(result, dict) and not result.get("error"):
|
|
||||||
logger.info(f"teacher succeeded; saved skill: {skill.get('name')}")
|
|
||||||
yield (
|
|
||||||
'data: ' + json.dumps({
|
|
||||||
"type": "skill_saved",
|
|
||||||
"name": skill.get("name"),
|
|
||||||
"category": skill.get("category", "general"),
|
|
||||||
}) + '\n\n'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
yield (
|
|
||||||
'data: ' + json.dumps({
|
|
||||||
"type": "skill_save_failed",
|
|
||||||
"reason": str(result),
|
|
||||||
}) + '\n\n'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"skill save raised: {e}")
|
|
||||||
yield (
|
yield (
|
||||||
'data: ' + json.dumps({
|
'data: ' + json.dumps({
|
||||||
"type": "skill_save_failed",
|
"type": "skill_save_failed",
|
||||||
"reason": str(e),
|
"reason": (
|
||||||
|
"Teacher-generated skills require an interactive exact "
|
||||||
|
"approval before they can be saved."
|
||||||
|
),
|
||||||
}) + '\n\n'
|
}) + '\n\n'
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
import uuid as _uuid
|
||||||
|
from src.tool_approvals import tool_approval_store
|
||||||
|
from src.tool_capabilities import capabilities_for_action
|
||||||
|
|
||||||
|
skill_content = _json.dumps(skill, ensure_ascii=False)
|
||||||
|
pending = tool_approval_store.create(
|
||||||
|
owner=owner,
|
||||||
|
session_id=session_id,
|
||||||
|
origin_run_id=f"teacher-skill-{_uuid.uuid4().hex}",
|
||||||
|
tool_name="manage_skills",
|
||||||
|
content=skill_content,
|
||||||
|
workspace=workspace,
|
||||||
|
external_untrusted_context_seen=True,
|
||||||
|
capabilities=capabilities_for_action("manage_skills", skill_content),
|
||||||
|
)
|
||||||
|
approval = pending.public_payload(
|
||||||
|
reason=(
|
||||||
|
"The teacher generated this reusable skill. Review and approve "
|
||||||
|
"the complete skill definition before it is saved."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
persisted_metrics = dict(captured_metrics)
|
||||||
|
persisted_tool_events = list(persisted_metrics.get("tool_events") or [])
|
||||||
|
persisted_round_texts = list(persisted_metrics.get("round_texts") or [])
|
||||||
|
prior_rounds = [
|
||||||
|
event.get("round")
|
||||||
|
for event in persisted_tool_events
|
||||||
|
if isinstance(event, dict) and isinstance(event.get("round"), int)
|
||||||
|
]
|
||||||
|
approval_round = max([len(persisted_round_texts), *prior_rounds, 0]) + 1
|
||||||
|
approval_tool_event = {
|
||||||
|
"round": approval_round,
|
||||||
|
"model": teacher_model,
|
||||||
|
"tool": "manage_skills",
|
||||||
|
"command": str(skill.get("name") or "teacher-generated skill"),
|
||||||
|
"output": "Waiting for an exact user approval.",
|
||||||
|
"exit_code": None,
|
||||||
|
"ask_user": approval,
|
||||||
|
}
|
||||||
|
persisted_tool_events.append(approval_tool_event)
|
||||||
|
persisted_metrics["tool_events"] = persisted_tool_events
|
||||||
|
persisted_metrics.setdefault("model", teacher_model)
|
||||||
|
yield (
|
||||||
|
"data: "
|
||||||
|
+ json.dumps({"delta": "Review the teacher-generated skill before saving it."})
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"data: "
|
||||||
|
+ json.dumps({
|
||||||
|
"type": "tool_output",
|
||||||
|
**approval_tool_event,
|
||||||
|
"teacher": True,
|
||||||
|
})
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"data: "
|
||||||
|
+ json.dumps({"type": "ask_user", "data": approval, "teacher": True})
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
# This must be the final metrics event: chat_routes saves only last_metrics
|
||||||
|
# when the outer stream reaches [DONE]. Without it, the live approval card
|
||||||
|
# disappears after a reload even though the server grant remains pending.
|
||||||
|
yield (
|
||||||
|
"data: "
|
||||||
|
+ json.dumps({"type": "metrics", "data": persisted_metrics, "teacher": True})
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
"""Opaque, exact, one-use approvals for tainted model-requested actions.
|
||||||
|
|
||||||
|
The model may propose an action after untrusted context, but only the server
|
||||||
|
stores and later executes the exact approved tool input. Browser-visible
|
||||||
|
fields are display copies, never authority.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
|
||||||
|
DEFAULT_MAX_PENDING_APPROVALS = 2048
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_owner(owner: Any) -> str:
|
||||||
|
return str(owner or "").strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_workspace(workspace: Any) -> str:
|
||||||
|
if not isinstance(workspace, str) or not workspace.strip():
|
||||||
|
return ""
|
||||||
|
return os.path.realpath(os.path.expanduser(workspace))
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_digest(payload: dict[str, Any]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def document_content_digest(content: Any) -> str:
|
||||||
|
"""Return the stable server-side fingerprint used to seal a document."""
|
||||||
|
return hashlib.sha256(str(content or "").encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _binding_payload(
|
||||||
|
*,
|
||||||
|
owner: Any,
|
||||||
|
session_id: Any,
|
||||||
|
origin_run_id: Any,
|
||||||
|
tool_name: Any,
|
||||||
|
content: Any,
|
||||||
|
workspace: Any,
|
||||||
|
document_id: Any,
|
||||||
|
document_version: Any,
|
||||||
|
document_digest: Any,
|
||||||
|
external_untrusted_context_seen: bool,
|
||||||
|
effects: tuple[str, ...],
|
||||||
|
result_integrity: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"owner": _normalized_owner(owner),
|
||||||
|
"session_id": str(session_id or ""),
|
||||||
|
"origin_run_id": str(origin_run_id or ""),
|
||||||
|
"tool_name": str(tool_name or ""),
|
||||||
|
"content": str(content or ""),
|
||||||
|
"workspace": _normalized_workspace(workspace),
|
||||||
|
"document_id": str(document_id or ""),
|
||||||
|
"document_version": (
|
||||||
|
int(document_version) if document_version is not None else None
|
||||||
|
),
|
||||||
|
"document_digest": str(document_digest or "").strip().lower(),
|
||||||
|
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
|
||||||
|
"effects": list(effects),
|
||||||
|
"result_integrity": str(result_integrity),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PendingToolApproval:
|
||||||
|
approval_id: str
|
||||||
|
owner: str
|
||||||
|
session_id: str
|
||||||
|
origin_run_id: str
|
||||||
|
tool_name: str
|
||||||
|
content: str
|
||||||
|
workspace: str
|
||||||
|
document_id: str
|
||||||
|
document_version: int | None
|
||||||
|
document_digest: str
|
||||||
|
external_untrusted_context_seen: bool
|
||||||
|
effects: tuple[str, ...]
|
||||||
|
result_integrity: str
|
||||||
|
digest: str
|
||||||
|
created_at: float
|
||||||
|
expires_at: float
|
||||||
|
|
||||||
|
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": "tool_approval",
|
||||||
|
"approval_id": self.approval_id,
|
||||||
|
"question": "Allow this exact action once?",
|
||||||
|
"description": reason or (
|
||||||
|
"Untrusted context influenced this run, so this action needs "
|
||||||
|
"your explicit approval."
|
||||||
|
),
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Allow once",
|
||||||
|
"value": "approve",
|
||||||
|
"description": "Execute only the sealed action shown here.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Deny",
|
||||||
|
"value": "deny",
|
||||||
|
"description": "Do not execute it.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"tool": self.tool_name,
|
||||||
|
# Show the complete sealed input so approval never hides
|
||||||
|
# trailing lines. This is not read back as authority.
|
||||||
|
"content": self.content,
|
||||||
|
"digest": self.digest[:16],
|
||||||
|
"effects": list(self.effects),
|
||||||
|
"workspace": self.workspace or None,
|
||||||
|
"document_id": self.document_id or None,
|
||||||
|
"document_version": self.document_version,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExactToolApproval:
|
||||||
|
"""A consumed grant that the dispatcher can claim exactly once."""
|
||||||
|
|
||||||
|
pending: PendingToolApproval
|
||||||
|
_claimed: bool = field(default=False, init=False, repr=False)
|
||||||
|
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
||||||
|
|
||||||
|
def _matches_unlocked(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: Any,
|
||||||
|
session_id: Any,
|
||||||
|
tool_name: Any,
|
||||||
|
content: Any,
|
||||||
|
workspace: Any,
|
||||||
|
) -> bool:
|
||||||
|
if self._claimed:
|
||||||
|
return False
|
||||||
|
capabilities = capabilities_for_action(tool_name, content)
|
||||||
|
effects = tuple(sorted(effect.value for effect in capabilities.effects))
|
||||||
|
result_integrity = capabilities.result_integrity.value
|
||||||
|
if (
|
||||||
|
effects != self.pending.effects
|
||||||
|
or result_integrity != self.pending.result_integrity
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
expected = _binding_payload(
|
||||||
|
owner=owner,
|
||||||
|
session_id=session_id,
|
||||||
|
origin_run_id=self.pending.origin_run_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
content=content,
|
||||||
|
workspace=workspace,
|
||||||
|
document_id=self.pending.document_id,
|
||||||
|
document_version=self.pending.document_version,
|
||||||
|
document_digest=self.pending.document_digest,
|
||||||
|
external_untrusted_context_seen=(
|
||||||
|
self.pending.external_untrusted_context_seen
|
||||||
|
),
|
||||||
|
effects=effects,
|
||||||
|
result_integrity=result_integrity,
|
||||||
|
)
|
||||||
|
return _canonical_digest(expected) == self.pending.digest
|
||||||
|
|
||||||
|
def matches(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: Any,
|
||||||
|
session_id: Any,
|
||||||
|
tool_name: Any,
|
||||||
|
content: Any,
|
||||||
|
workspace: Any,
|
||||||
|
) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return self._matches_unlocked(
|
||||||
|
owner=owner,
|
||||||
|
session_id=session_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
content=content,
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
def claim(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: Any,
|
||||||
|
session_id: Any,
|
||||||
|
tool_name: Any,
|
||||||
|
content: Any,
|
||||||
|
workspace: Any,
|
||||||
|
) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if not self._matches_unlocked(
|
||||||
|
owner=owner,
|
||||||
|
session_id=session_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
content=content,
|
||||||
|
workspace=workspace,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
self._claimed = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalStore:
|
||||||
|
"""Thread-safe pending approval registry with destructive consumption."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ttl_seconds: int = DEFAULT_APPROVAL_TTL_SECONDS,
|
||||||
|
max_pending: int = DEFAULT_MAX_PENDING_APPROVALS,
|
||||||
|
):
|
||||||
|
self._ttl_seconds = max(1, int(ttl_seconds))
|
||||||
|
self._max_pending = max(1, int(max_pending))
|
||||||
|
self._pending: dict[str, PendingToolApproval] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _purge_expired_locked(self, now: float) -> None:
|
||||||
|
expired = [
|
||||||
|
approval_id
|
||||||
|
for approval_id, pending in self._pending.items()
|
||||||
|
if pending.expires_at <= now
|
||||||
|
]
|
||||||
|
for approval_id in expired:
|
||||||
|
self._pending.pop(approval_id, None)
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: Any,
|
||||||
|
session_id: Any,
|
||||||
|
origin_run_id: Any,
|
||||||
|
tool_name: Any,
|
||||||
|
content: Any,
|
||||||
|
workspace: Any,
|
||||||
|
document_id: Any = None,
|
||||||
|
document_version: Any = None,
|
||||||
|
document_digest: Any = None,
|
||||||
|
external_untrusted_context_seen: bool,
|
||||||
|
capabilities: ToolCapabilities,
|
||||||
|
) -> PendingToolApproval:
|
||||||
|
now = time.time()
|
||||||
|
effects = tuple(sorted(effect.value for effect in capabilities.effects))
|
||||||
|
result_integrity = capabilities.result_integrity.value
|
||||||
|
payload = _binding_payload(
|
||||||
|
owner=owner,
|
||||||
|
session_id=session_id,
|
||||||
|
origin_run_id=origin_run_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
content=content,
|
||||||
|
workspace=workspace,
|
||||||
|
document_id=document_id,
|
||||||
|
document_version=document_version,
|
||||||
|
document_digest=document_digest,
|
||||||
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
|
effects=effects,
|
||||||
|
result_integrity=result_integrity,
|
||||||
|
)
|
||||||
|
pending = PendingToolApproval(
|
||||||
|
approval_id=secrets.token_urlsafe(32),
|
||||||
|
owner=payload["owner"],
|
||||||
|
session_id=payload["session_id"],
|
||||||
|
origin_run_id=payload["origin_run_id"],
|
||||||
|
tool_name=payload["tool_name"],
|
||||||
|
content=payload["content"],
|
||||||
|
workspace=payload["workspace"],
|
||||||
|
document_id=payload["document_id"],
|
||||||
|
document_version=payload["document_version"],
|
||||||
|
document_digest=payload["document_digest"],
|
||||||
|
external_untrusted_context_seen=payload[
|
||||||
|
"external_untrusted_context_seen"
|
||||||
|
],
|
||||||
|
effects=effects,
|
||||||
|
result_integrity=result_integrity,
|
||||||
|
digest=_canonical_digest(payload),
|
||||||
|
created_at=now,
|
||||||
|
expires_at=now + self._ttl_seconds,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._purge_expired_locked(now)
|
||||||
|
# The chat UI exposes one pending card per session, so supersede an
|
||||||
|
# older action there. Headless/manual-test callers use an empty
|
||||||
|
# session id; keep independent origin runs separate so two skill
|
||||||
|
# tests owned by the same user cannot invalidate each other.
|
||||||
|
superseded = [
|
||||||
|
approval_id
|
||||||
|
for approval_id, existing in self._pending.items()
|
||||||
|
if (
|
||||||
|
existing.owner == pending.owner
|
||||||
|
and existing.session_id == pending.session_id
|
||||||
|
and (
|
||||||
|
bool(pending.session_id)
|
||||||
|
or existing.origin_run_id == pending.origin_run_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for approval_id in superseded:
|
||||||
|
self._pending.pop(approval_id, None)
|
||||||
|
while len(self._pending) >= self._max_pending:
|
||||||
|
oldest_id = min(
|
||||||
|
self._pending,
|
||||||
|
key=lambda approval_id: self._pending[approval_id].created_at,
|
||||||
|
)
|
||||||
|
self._pending.pop(oldest_id, None)
|
||||||
|
self._pending[pending.approval_id] = pending
|
||||||
|
return pending
|
||||||
|
|
||||||
|
def consume(
|
||||||
|
self,
|
||||||
|
approval_id: Any,
|
||||||
|
*,
|
||||||
|
decision: Any,
|
||||||
|
owner: Any,
|
||||||
|
session_id: Any,
|
||||||
|
) -> ExactToolApproval | None:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
self._purge_expired_locked(now)
|
||||||
|
approval_key = str(approval_id or "")
|
||||||
|
pending = self._pending.get(approval_key)
|
||||||
|
if pending is None:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
pending.owner != _normalized_owner(owner)
|
||||||
|
or pending.session_id != str(session_id or "")
|
||||||
|
):
|
||||||
|
# Authentication is checked before destructive consumption so
|
||||||
|
# a leaked/guessed opaque id cannot be used to invalidate
|
||||||
|
# another owner's pending action.
|
||||||
|
return None
|
||||||
|
self._pending.pop(approval_key, None)
|
||||||
|
if str(decision or "").strip().lower() != "approve":
|
||||||
|
return None
|
||||||
|
return ExactToolApproval(pending)
|
||||||
|
|
||||||
|
def peek(self, approval_id: Any) -> PendingToolApproval | None:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
self._purge_expired_locked(now)
|
||||||
|
return self._pending.get(str(approval_id or ""))
|
||||||
|
|
||||||
|
def retire_for_session(self, *, owner: Any, session_id: Any) -> bool:
|
||||||
|
"""Discard pending actions superseded by an ordinary user turn.
|
||||||
|
|
||||||
|
Returns whether any retired action carried external provenance, so the
|
||||||
|
caller can preserve that security state without treating the new user
|
||||||
|
message as an approval continuation.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
normalized_owner = _normalized_owner(owner)
|
||||||
|
normalized_session = str(session_id or "")
|
||||||
|
if not normalized_session:
|
||||||
|
return False
|
||||||
|
with self._lock:
|
||||||
|
self._purge_expired_locked(now)
|
||||||
|
retired_ids = [
|
||||||
|
approval_id
|
||||||
|
for approval_id, pending in self._pending.items()
|
||||||
|
if (
|
||||||
|
pending.owner == normalized_owner
|
||||||
|
and pending.session_id == normalized_session
|
||||||
|
)
|
||||||
|
]
|
||||||
|
carried_taint = any(
|
||||||
|
self._pending[approval_id].external_untrusted_context_seen
|
||||||
|
for approval_id in retired_ids
|
||||||
|
)
|
||||||
|
for approval_id in retired_ids:
|
||||||
|
self._pending.pop(approval_id, None)
|
||||||
|
return carried_taint
|
||||||
|
|
||||||
|
|
||||||
|
tool_approval_store = ToolApprovalStore()
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
"""Deterministic capability metadata for agent tools.
|
||||||
|
|
||||||
|
Model output requests an action; it never supplies the authority for that
|
||||||
|
action. This module classifies the effects of each built-in tool and applies
|
||||||
|
run-local integrity gates before dispatch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
class ToolEffect(str, Enum):
|
||||||
|
READ_PUBLIC = "read_public"
|
||||||
|
READ_WORKSPACE = "read_workspace"
|
||||||
|
READ_PRIVATE = "read_private"
|
||||||
|
WRITE_WORKSPACE = "write_workspace"
|
||||||
|
WRITE_PRIVATE = "write_private"
|
||||||
|
EXECUTE_CODE = "execute_code"
|
||||||
|
BROKERED_NETWORK_READ = "brokered_network_read"
|
||||||
|
NETWORK_EGRESS = "network_egress"
|
||||||
|
EXTERNAL_SIDE_EFFECT = "external_side_effect"
|
||||||
|
UI_SIDE_EFFECT = "ui_side_effect"
|
||||||
|
ADMIN_CHANGE = "admin_change"
|
||||||
|
DESTRUCTIVE = "destructive"
|
||||||
|
USER_INTERACTION = "user_interaction"
|
||||||
|
|
||||||
|
|
||||||
|
class ResultIntegrity(str, Enum):
|
||||||
|
SYSTEM = "system"
|
||||||
|
WORKSPACE_UNTRUSTED = "workspace_untrusted"
|
||||||
|
EXTERNAL_UNTRUSTED = "external_untrusted"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolCapabilities:
|
||||||
|
effects: frozenset[ToolEffect]
|
||||||
|
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM
|
||||||
|
known: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _capabilities(
|
||||||
|
*effects: ToolEffect,
|
||||||
|
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM,
|
||||||
|
) -> ToolCapabilities:
|
||||||
|
return ToolCapabilities(frozenset(effects), result_integrity)
|
||||||
|
|
||||||
|
|
||||||
|
_REGISTRY: dict[str, ToolCapabilities] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _register(
|
||||||
|
names: Iterable[str],
|
||||||
|
*effects: ToolEffect,
|
||||||
|
result_integrity: ResultIntegrity = ResultIntegrity.SYSTEM,
|
||||||
|
) -> None:
|
||||||
|
capabilities = _capabilities(*effects, result_integrity=result_integrity)
|
||||||
|
for name in names:
|
||||||
|
if name in _REGISTRY:
|
||||||
|
raise RuntimeError(f"Duplicate tool capability classification: {name}")
|
||||||
|
_REGISTRY[name] = capabilities
|
||||||
|
|
||||||
|
|
||||||
|
_register(
|
||||||
|
{"ask_user", "update_plan"},
|
||||||
|
ToolEffect.USER_INTERACTION,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{
|
||||||
|
"list_cached_models",
|
||||||
|
"list_cookbook_servers",
|
||||||
|
"list_downloads",
|
||||||
|
"list_models",
|
||||||
|
"list_serve_presets",
|
||||||
|
"list_served_models",
|
||||||
|
},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
# These readers return provider-controlled model identifiers or durable
|
||||||
|
# user/admin-authored Cookbook and process state. Local brokering does not
|
||||||
|
# make the returned text server-authored.
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"search_hf_models"},
|
||||||
|
ToolEffect.BROKERED_NETWORK_READ,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"get_workspace", "glob", "grep", "ls", "read_file"},
|
||||||
|
ToolEffect.READ_WORKSPACE,
|
||||||
|
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"web_search"},
|
||||||
|
ToolEffect.BROKERED_NETWORK_READ,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"web_fetch"},
|
||||||
|
ToolEffect.BROKERED_NETWORK_READ,
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{
|
||||||
|
"list_email_accounts",
|
||||||
|
"list_emails",
|
||||||
|
"read_email",
|
||||||
|
"resolve_contact",
|
||||||
|
"scan_email_unsubscribes",
|
||||||
|
"search_chats",
|
||||||
|
"search_emails",
|
||||||
|
"list_sessions",
|
||||||
|
"tail_serve_output",
|
||||||
|
"vault_get",
|
||||||
|
"vault_search",
|
||||||
|
},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"bash", "manage_bg_jobs", "python"},
|
||||||
|
ToolEffect.EXECUTE_CODE,
|
||||||
|
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"apply_patch", "edit_file", "write_file"},
|
||||||
|
ToolEffect.WRITE_WORKSPACE,
|
||||||
|
# Successful writes include unified diffs that can echo arbitrary existing
|
||||||
|
# workspace content back into the next model round.
|
||||||
|
result_integrity=ResultIntegrity.WORKSPACE_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{
|
||||||
|
"create_document",
|
||||||
|
"manage_calendar",
|
||||||
|
"manage_contact",
|
||||||
|
"manage_documents",
|
||||||
|
"manage_memory",
|
||||||
|
"manage_notes",
|
||||||
|
"manage_research",
|
||||||
|
"manage_session",
|
||||||
|
"manage_skills",
|
||||||
|
"manage_tasks",
|
||||||
|
"suggest_document",
|
||||||
|
"todowrite",
|
||||||
|
},
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{
|
||||||
|
"ai_draft_email_reply",
|
||||||
|
"create_session",
|
||||||
|
"draft_email",
|
||||||
|
"draft_email_reply",
|
||||||
|
},
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
# These tools resolve user-configured endpoints/accounts or read stored
|
||||||
|
# email content before returning model-visible status text.
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"edit_document", "update_document"},
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
# These tools can echo stored document content that was not present in
|
||||||
|
# their arguments. edit_document returns the complete edited document;
|
||||||
|
# update_document also preserves stored email headers/thread history.
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"pipeline"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"send_to_session"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"chat_with_model", "ask_teacher"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"download_attachment"},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
ToolEffect.WRITE_WORKSPACE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"edit_image", "generate_image", "trigger_research"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{
|
||||||
|
"archive_email",
|
||||||
|
"bulk_email",
|
||||||
|
"mark_email_read",
|
||||||
|
"reply_to_email",
|
||||||
|
"send_email",
|
||||||
|
"unsubscribe_email",
|
||||||
|
},
|
||||||
|
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||||
|
# Email action results can include stored headers/account labels or remote
|
||||||
|
# SMTP/IMAP responses, even when the action itself succeeded.
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"delete_email"},
|
||||||
|
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||||
|
ToolEffect.DESTRUCTIVE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"ui_control"},
|
||||||
|
ToolEffect.UI_SIDE_EFFECT,
|
||||||
|
# Model switches and custom-theme validation read mutable user settings.
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{
|
||||||
|
"adopt_served_model",
|
||||||
|
"cancel_download",
|
||||||
|
"download_model",
|
||||||
|
"serve_model",
|
||||||
|
"serve_preset",
|
||||||
|
"stop_served_model",
|
||||||
|
"vault_unlock",
|
||||||
|
},
|
||||||
|
ToolEffect.ADMIN_CHANGE,
|
||||||
|
# Cookbook/process operations can return stored presets, provider data,
|
||||||
|
# remote shell output, and command errors.
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{
|
||||||
|
"api_call",
|
||||||
|
"app_api",
|
||||||
|
"manage_endpoints",
|
||||||
|
"manage_mcp",
|
||||||
|
"manage_settings",
|
||||||
|
"manage_tokens",
|
||||||
|
"manage_webhooks",
|
||||||
|
},
|
||||||
|
ToolEffect.ADMIN_CHANGE,
|
||||||
|
# api_call/app_api return remote or stored application data, and the
|
||||||
|
# admin managers can echo user-controlled configuration. Conservatively
|
||||||
|
# retain the action effect while treating every successful result as data.
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_CAPABILITIES: Mapping[str, ToolCapabilities] = MappingProxyType(dict(_REGISTRY))
|
||||||
|
KNOWN_CAPABILITY_TOOLS = frozenset(TOOL_CAPABILITIES)
|
||||||
|
|
||||||
|
_UNKNOWN_CAPABILITIES = _capabilities(
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
ToolEffect.WRITE_WORKSPACE,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
ToolEffect.EXECUTE_CODE,
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||||
|
ToolEffect.ADMIN_CHANGE,
|
||||||
|
ToolEffect.DESTRUCTIVE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_UNKNOWN_CAPABILITIES = ToolCapabilities(
|
||||||
|
_UNKNOWN_CAPABILITIES.effects,
|
||||||
|
_UNKNOWN_CAPABILITIES.result_integrity,
|
||||||
|
known=False,
|
||||||
|
)
|
||||||
|
_BROWSER_MCP_READ_CAPABILITIES = _capabilities(
|
||||||
|
ToolEffect.BROKERED_NETWORK_READ,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_BROWSER_MCP_READ_TOOLS = frozenset(
|
||||||
|
{
|
||||||
|
"mcp__builtin_browser__browser_console_messages",
|
||||||
|
"mcp__builtin_browser__browser_network_requests",
|
||||||
|
"mcp__builtin_browser__browser_snapshot",
|
||||||
|
"mcp__builtin_browser__browser_take_screenshot",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
||||||
|
"""Return deterministic capabilities; malformed and unknown tools fail high."""
|
||||||
|
if not isinstance(tool_name, str) or not tool_name:
|
||||||
|
return _UNKNOWN_CAPABILITIES
|
||||||
|
capabilities = TOOL_CAPABILITIES.get(tool_name)
|
||||||
|
if capabilities is not None:
|
||||||
|
return capabilities
|
||||||
|
if tool_name.startswith("mcp__email__"):
|
||||||
|
bare_name = tool_name[len("mcp__email__"):]
|
||||||
|
capabilities = TOOL_CAPABILITIES.get(bare_name)
|
||||||
|
if bare_name in BUILTIN_EMAIL_TOOLS and capabilities is not None:
|
||||||
|
return capabilities
|
||||||
|
if tool_name in _BROWSER_MCP_READ_TOOLS:
|
||||||
|
return _BROWSER_MCP_READ_CAPABILITIES
|
||||||
|
return _UNKNOWN_CAPABILITIES
|
||||||
|
|
||||||
|
|
||||||
|
_PRIVATE_ACTION_READS: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"manage_calendar": frozenset({"list_calendars", "list_events"}),
|
||||||
|
"manage_contact": frozenset({"list"}),
|
||||||
|
"manage_documents": frozenset({"list", "read", "view", "open", "get"}),
|
||||||
|
"manage_memory": frozenset({"list", "search"}),
|
||||||
|
"manage_notes": frozenset({"list", "search", "find", "view"}),
|
||||||
|
"manage_research": frozenset({"list", "read", "open", "view", "get"}),
|
||||||
|
"manage_session": frozenset({"list", "switch", "open", "select", "view"}),
|
||||||
|
"manage_skills": frozenset({"list", "index", "view", "view_ref", "search"}),
|
||||||
|
"manage_tasks": frozenset({"list"}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"manage_calendar": frozenset(
|
||||||
|
{"create_event", "update_event", "delete_event"}
|
||||||
|
),
|
||||||
|
"manage_contact": frozenset({"add", "update", "edit", "delete"}),
|
||||||
|
"manage_documents": frozenset({"delete", "tidy"}),
|
||||||
|
"manage_memory": frozenset({"add", "edit", "delete"}),
|
||||||
|
"manage_notes": frozenset({"add", "update", "delete", "toggle_item"}),
|
||||||
|
"manage_research": frozenset({"delete"}),
|
||||||
|
"manage_session": frozenset(
|
||||||
|
{
|
||||||
|
"rename",
|
||||||
|
"archive",
|
||||||
|
"unarchive",
|
||||||
|
"delete",
|
||||||
|
"important",
|
||||||
|
"unimportant",
|
||||||
|
"truncate",
|
||||||
|
"fork",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"manage_skills": frozenset({"add", "edit", "patch", "publish", "delete"}),
|
||||||
|
"manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_ACTION_DESTRUCTIVE: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"manage_calendar": frozenset({"delete_event"}),
|
||||||
|
"manage_contact": frozenset({"delete"}),
|
||||||
|
"manage_documents": frozenset({"delete", "tidy"}),
|
||||||
|
"manage_endpoints": frozenset({"delete"}),
|
||||||
|
"manage_bg_jobs": frozenset({"kill", "stop", "cancel", "terminate"}),
|
||||||
|
"manage_memory": frozenset({"delete"}),
|
||||||
|
"manage_mcp": frozenset({"delete"}),
|
||||||
|
"manage_notes": frozenset({"delete"}),
|
||||||
|
"manage_research": frozenset({"delete"}),
|
||||||
|
"manage_session": frozenset({"delete", "truncate"}),
|
||||||
|
"manage_settings": frozenset({"delete", "reset"}),
|
||||||
|
"manage_skills": frozenset({"delete"}),
|
||||||
|
"manage_tasks": frozenset({"delete"}),
|
||||||
|
"manage_tokens": frozenset({"delete"}),
|
||||||
|
"manage_webhooks": frozenset({"delete"}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_ACTION_DEFAULTS: Mapping[str, str] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"manage_calendar": "list_events",
|
||||||
|
"manage_documents": "list",
|
||||||
|
"manage_research": "list",
|
||||||
|
"manage_tasks": "list",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_ACTION_ALIASES: Mapping[str, Mapping[str, str]] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"manage_calendar": MappingProxyType(
|
||||||
|
{
|
||||||
|
"create": "create_event",
|
||||||
|
"update": "update_event",
|
||||||
|
"delete": "delete_event",
|
||||||
|
"list": "list_events",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"manage_notes": MappingProxyType(
|
||||||
|
{
|
||||||
|
"create": "add",
|
||||||
|
"new": "add",
|
||||||
|
"save": "add",
|
||||||
|
"remind": "add",
|
||||||
|
"reminder": "add",
|
||||||
|
"remove": "delete",
|
||||||
|
"remove_item": "toggle_item",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_LINE_ACTION_TOOLS = frozenset({"manage_memory", "manage_session"})
|
||||||
|
|
||||||
|
|
||||||
|
def _action_from_content(tool_name: str, content: Any) -> str | None:
|
||||||
|
"""Extract the action discriminator using the same accepted input shapes."""
|
||||||
|
if isinstance(content, Mapping):
|
||||||
|
payload: Any = dict(content)
|
||||||
|
elif isinstance(content, str):
|
||||||
|
raw = content.strip()
|
||||||
|
if tool_name in _LINE_ACTION_TOOLS and raw and not raw.startswith("{"):
|
||||||
|
return raw.splitlines()[0].strip().replace("-", "_").casefold() or None
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw) if raw else {}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
len(payload) == 1
|
||||||
|
and isinstance(payload.get("body"), dict)
|
||||||
|
and "action" in payload["body"]
|
||||||
|
):
|
||||||
|
payload = payload["body"]
|
||||||
|
|
||||||
|
action = payload.get("action")
|
||||||
|
if (
|
||||||
|
not action
|
||||||
|
and tool_name == "manage_calendar"
|
||||||
|
and isinstance(payload.get("events"), list)
|
||||||
|
):
|
||||||
|
action = "create_event"
|
||||||
|
if not action and tool_name == "manage_tasks" and any(
|
||||||
|
payload.get(key) is not None
|
||||||
|
for key in ("task", "description", "schedule", "time", "day_of_week")
|
||||||
|
):
|
||||||
|
action = "create"
|
||||||
|
if not isinstance(action, str) or not action.strip():
|
||||||
|
action = _ACTION_DEFAULTS.get(tool_name)
|
||||||
|
if not action:
|
||||||
|
return None
|
||||||
|
normalized = action.strip().replace("-", "_").casefold()
|
||||||
|
return _ACTION_ALIASES.get(tool_name, {}).get(normalized, normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities:
|
||||||
|
"""Classify a sealed multiplexed action; ambiguous actions fail high."""
|
||||||
|
base = capabilities_for_tool(tool_name)
|
||||||
|
if not isinstance(tool_name, str):
|
||||||
|
return base
|
||||||
|
|
||||||
|
action = _action_from_content(tool_name, content)
|
||||||
|
destructive = action in _ACTION_DESTRUCTIVE.get(tool_name, ())
|
||||||
|
if tool_name not in _PRIVATE_ACTION_READS:
|
||||||
|
if not destructive:
|
||||||
|
return base
|
||||||
|
return ToolCapabilities(
|
||||||
|
frozenset(set(base.effects) | {ToolEffect.DESTRUCTIVE}),
|
||||||
|
base.result_integrity,
|
||||||
|
known=base.known,
|
||||||
|
)
|
||||||
|
if action in _PRIVATE_ACTION_READS[tool_name]:
|
||||||
|
return _capabilities(
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
if action in _PRIVATE_ACTION_WRITES[tool_name]:
|
||||||
|
effects = set(base.effects)
|
||||||
|
if destructive:
|
||||||
|
effects.add(ToolEffect.DESTRUCTIVE)
|
||||||
|
return ToolCapabilities(
|
||||||
|
frozenset(effects),
|
||||||
|
ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
known=base.known,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _capabilities(
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tool_result_is_successful(result: Any) -> bool:
|
||||||
|
"""Return whether a result actually introduced successful tool output."""
|
||||||
|
return bool(
|
||||||
|
isinstance(result, dict)
|
||||||
|
and not result.get("blocked")
|
||||||
|
and not result.get("approval_required")
|
||||||
|
and not result.get("error")
|
||||||
|
and result.get("exit_code") in (None, 0)
|
||||||
|
and result.get("success") is not False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tool_result_should_arm_gate(
|
||||||
|
tool_name: Any,
|
||||||
|
result: Any,
|
||||||
|
content: Any = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a result introduced non-system content to the model.
|
||||||
|
|
||||||
|
A blocked/approval placeholder and a genuinely content-free failure do not
|
||||||
|
change authority. Once a non-system tool returns text or structured data
|
||||||
|
that will be folded into model context, however, failure status cannot make
|
||||||
|
that payload trusted: MCP ``isError`` text, provider exception messages,
|
||||||
|
and HTTP error bodies are all attacker-controlled input surfaces.
|
||||||
|
"""
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return False
|
||||||
|
if result.get("blocked") or result.get("approval_required"):
|
||||||
|
return False
|
||||||
|
# A producer that knows a particular response body came from a remote or
|
||||||
|
# stored source overrides a coarse static SYSTEM default.
|
||||||
|
if result.get("untrusted_content") is True:
|
||||||
|
return True
|
||||||
|
capabilities = capabilities_for_action(tool_name, content)
|
||||||
|
if capabilities.result_integrity is ResultIntegrity.SYSTEM:
|
||||||
|
return False
|
||||||
|
if tool_result_is_successful(result):
|
||||||
|
return True
|
||||||
|
# ``format_tool_result`` serializes every additional structured field, so
|
||||||
|
# a fixed allowlist here would inevitably miss model-visible payloads such
|
||||||
|
# as ``details``, ``events``, or provider-specific response keys. Exclude
|
||||||
|
# only status/policy controls that carry no producer content; any other
|
||||||
|
# non-empty field crosses the same integrity boundary even on failure.
|
||||||
|
non_content_keys = frozenset(
|
||||||
|
{
|
||||||
|
"approval_required",
|
||||||
|
"blocked",
|
||||||
|
"exit_code",
|
||||||
|
"policy",
|
||||||
|
"success",
|
||||||
|
"untrusted_content",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return any(
|
||||||
|
key not in non_content_keys and value not in (None, "", [], {}, ())
|
||||||
|
for key, value in result.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
POST_EXTERNAL_BLOCKED_EFFECTS = frozenset(
|
||||||
|
{
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
ToolEffect.WRITE_WORKSPACE,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
|
ToolEffect.EXECUTE_CODE,
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||||
|
ToolEffect.UI_SIDE_EFFECT,
|
||||||
|
ToolEffect.ADMIN_CHANGE,
|
||||||
|
ToolEffect.DESTRUCTIVE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolGateDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_EXTERNAL_MESSAGE_SOURCES = frozenset(
|
||||||
|
{
|
||||||
|
"injected research context",
|
||||||
|
"prefetched search context",
|
||||||
|
"research context",
|
||||||
|
"web search results",
|
||||||
|
"youtube transcript",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_EXTERNAL_MESSAGE_SOURCE_PREFIXES = ("web page:",)
|
||||||
|
|
||||||
|
|
||||||
|
def messages_contain_external_untrusted_context(messages: Iterable[dict]) -> bool:
|
||||||
|
"""Detect explicitly labelled external context already present in a run."""
|
||||||
|
for message in messages or ():
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
continue
|
||||||
|
metadata = message.get("metadata")
|
||||||
|
if not isinstance(metadata, dict) or metadata.get("trusted") is not False:
|
||||||
|
continue
|
||||||
|
gate_marker = metadata.get("tool_gate_untrusted")
|
||||||
|
if gate_marker is True:
|
||||||
|
return True
|
||||||
|
if gate_marker is False:
|
||||||
|
# Explicit current-format opt-outs are authoritative. The source
|
||||||
|
# label heuristics below exist only for older saved wrappers that
|
||||||
|
# predate the marker.
|
||||||
|
continue
|
||||||
|
if metadata.get("provenance_origin") == "external":
|
||||||
|
return True
|
||||||
|
source = metadata.get("source")
|
||||||
|
if not isinstance(source, str):
|
||||||
|
continue
|
||||||
|
normalized_source = source.strip().casefold()
|
||||||
|
if normalized_source in _EXTERNAL_MESSAGE_SOURCES:
|
||||||
|
return True
|
||||||
|
if normalized_source.startswith(_EXTERNAL_MESSAGE_SOURCE_PREFIXES):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolRunSecurityContext:
|
||||||
|
"""Server-owned integrity state for one agent run."""
|
||||||
|
|
||||||
|
external_untrusted_context_seen: bool = False
|
||||||
|
external_sources: list[str] = field(default_factory=list)
|
||||||
|
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
|
||||||
|
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||||
|
"""Promote any server-labelled untrusted prompt context into the gate."""
|
||||||
|
if messages_contain_external_untrusted_context(messages):
|
||||||
|
self.external_untrusted_context_seen = True
|
||||||
|
|
||||||
|
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||||
|
if not self.external_untrusted_context_seen:
|
||||||
|
return ToolGateDecision(True)
|
||||||
|
capabilities = capabilities_for_action(tool_name, content)
|
||||||
|
blocked_effects = capabilities.effects & POST_EXTERNAL_BLOCKED_EFFECTS
|
||||||
|
if capabilities.known and not blocked_effects:
|
||||||
|
return ToolGateDecision(True)
|
||||||
|
effects = ", ".join(sorted(effect.value for effect in blocked_effects))
|
||||||
|
if not capabilities.known:
|
||||||
|
effects = "unknown/high-impact"
|
||||||
|
return ToolGateDecision(
|
||||||
|
False,
|
||||||
|
(
|
||||||
|
"External untrusted context has already influenced this run. "
|
||||||
|
f"Tool '{tool_name}' requires a separate user-authorized action "
|
||||||
|
f"because it can cause {effects}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def observe_tool_result(
|
||||||
|
self,
|
||||||
|
tool_name: Any,
|
||||||
|
result: Any,
|
||||||
|
content: Any = None,
|
||||||
|
) -> None:
|
||||||
|
if not tool_result_should_arm_gate(tool_name, result, content):
|
||||||
|
return
|
||||||
|
self.external_untrusted_context_seen = True
|
||||||
|
if isinstance(tool_name, str) and tool_name not in self.external_sources:
|
||||||
|
self.external_sources.append(tool_name)
|
||||||
|
|
||||||
|
|
||||||
|
def blocked_tool_result(tool_name: Any, reason: str) -> tuple[str, dict]:
|
||||||
|
return (
|
||||||
|
f"{tool_name}: BLOCKED",
|
||||||
|
{
|
||||||
|
"error": reason,
|
||||||
|
"exit_code": 1,
|
||||||
|
"blocked": True,
|
||||||
|
"policy": "external_untrusted_context",
|
||||||
|
},
|
||||||
|
)
|
||||||
+161
-2
@@ -27,10 +27,24 @@ from src.tool_security import (
|
|||||||
is_public_blocked_tool,
|
is_public_blocked_tool,
|
||||||
owner_is_admin_or_single_user,
|
owner_is_admin_or_single_user,
|
||||||
)
|
)
|
||||||
|
from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result
|
||||||
|
from src.tool_approvals import ExactToolApproval
|
||||||
from src.tool_policy import ToolPolicy
|
from src.tool_policy import ToolPolicy
|
||||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
|
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
|
||||||
from src.tool_utils import _truncate, get_mcp_manager
|
from src.tool_utils import _truncate, get_mcp_manager
|
||||||
|
|
||||||
|
|
||||||
|
class _MissingToolSecurityContext:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _NoToolSecurityContext:
|
||||||
|
"""Explicit sentinel for non-agent callers that have no run provenance."""
|
||||||
|
|
||||||
|
|
||||||
|
_MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
|
||||||
|
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
|
||||||
|
|
||||||
# Persistent working directory for agent subprocesses.
|
# Persistent working directory for agent subprocesses.
|
||||||
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||||
# (/app/data) and the local data directory for manual installs.
|
# (/app/data) and the local data directory for manual installs.
|
||||||
@@ -554,10 +568,19 @@ async def _document_tool_dispatch(
|
|||||||
content: str,
|
content: str,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
|
document_id: Optional[str] = None,
|
||||||
|
document_version: Optional[int] = None,
|
||||||
|
document_digest: Optional[str] = None,
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict]:
|
||||||
"""Route a document tool through TOOL_HANDLERS with the right ctx shape."""
|
"""Route a document tool through TOOL_HANDLERS with the right ctx shape."""
|
||||||
from src.agent_tools import TOOL_HANDLERS
|
from src.agent_tools import TOOL_HANDLERS
|
||||||
ctx = {"session_id": session_id, "owner": owner}
|
ctx = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"owner": owner,
|
||||||
|
"doc_id": document_id,
|
||||||
|
"expected_document_version": document_version,
|
||||||
|
"expected_document_digest": document_digest,
|
||||||
|
}
|
||||||
if tool in TOOL_HANDLERS:
|
if tool in TOOL_HANDLERS:
|
||||||
return await TOOL_HANDLERS[tool](content, ctx)
|
return await TOOL_HANDLERS[tool](content, ctx)
|
||||||
return None
|
return None
|
||||||
@@ -575,6 +598,12 @@ async def execute_tool_block(
|
|||||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||||
workspace: Optional[str] = None,
|
workspace: Optional[str] = None,
|
||||||
tool_policy: Optional[Any] = None,
|
tool_policy: Optional[Any] = None,
|
||||||
|
security_context: (
|
||||||
|
ToolRunSecurityContext
|
||||||
|
| _NoToolSecurityContext
|
||||||
|
| _MissingToolSecurityContext
|
||||||
|
) = _MISSING_TOOL_SECURITY_CONTEXT,
|
||||||
|
exact_approval: Optional[ExactToolApproval] = None,
|
||||||
) -> Tuple[str, Dict]:
|
) -> Tuple[str, Dict]:
|
||||||
"""Execute a single tool block. Returns (description, result_dict).
|
"""Execute a single tool block. Returns (description, result_dict).
|
||||||
|
|
||||||
@@ -582,6 +611,104 @@ async def execute_tool_block(
|
|||||||
cwd confine to it) for the duration of this call, then delegate. Reset on the
|
cwd confine to it) for the duration of this call, then delegate. Reset on the
|
||||||
way out so the binding never leaks to the next tool call.
|
way out so the binding never leaks to the next tool call.
|
||||||
"""
|
"""
|
||||||
|
if security_context is _MISSING_TOOL_SECURITY_CONTEXT:
|
||||||
|
raise TypeError(
|
||||||
|
"execute_tool_block requires security_context; pass a "
|
||||||
|
"ToolRunSecurityContext or NO_TOOL_SECURITY_CONTEXT explicitly"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(security_context, ToolRunSecurityContext)
|
||||||
|
and security_context is not NO_TOOL_SECURITY_CONTEXT
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"security_context must be a ToolRunSecurityContext or "
|
||||||
|
"NO_TOOL_SECURITY_CONTEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
approval_claimed = False
|
||||||
|
if exact_approval is not None:
|
||||||
|
if (
|
||||||
|
not isinstance(security_context, ToolRunSecurityContext)
|
||||||
|
or not security_context.external_untrusted_context_seen
|
||||||
|
or not exact_approval.pending.external_untrusted_context_seen
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
f"{getattr(block, 'tool_type', None)}: BLOCKED",
|
||||||
|
{
|
||||||
|
"error": "Exact-action approval requires an armed run security context.",
|
||||||
|
"exit_code": 1,
|
||||||
|
"blocked": True,
|
||||||
|
"policy": "exact_tool_approval",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
exact_approval.pending.tool_name
|
||||||
|
in {"edit_document", "suggest_document", "update_document"}
|
||||||
|
and (
|
||||||
|
not exact_approval.pending.document_id
|
||||||
|
or exact_approval.pending.document_version is None
|
||||||
|
or not exact_approval.pending.document_digest
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
f"{getattr(block, 'tool_type', None)}: BLOCKED",
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
"The approved document action has no sealed target and "
|
||||||
|
"cannot be executed."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
"blocked": True,
|
||||||
|
"policy": "exact_tool_approval",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sealed_workspace = exact_approval.pending.workspace
|
||||||
|
if sealed_workspace and vet_workspace(sealed_workspace) != sealed_workspace:
|
||||||
|
return (
|
||||||
|
f"{getattr(block, 'tool_type', None)}: BLOCKED",
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
"The approved workspace is no longer a valid safe "
|
||||||
|
"directory. Review the action again."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
"blocked": True,
|
||||||
|
"policy": "exact_tool_approval",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
approval_claimed = exact_approval.claim(
|
||||||
|
owner=owner,
|
||||||
|
session_id=session_id,
|
||||||
|
tool_name=getattr(block, "tool_type", None),
|
||||||
|
content=getattr(block, "content", None),
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
if not approval_claimed:
|
||||||
|
return (
|
||||||
|
f"{getattr(block, 'tool_type', None)}: BLOCKED",
|
||||||
|
{
|
||||||
|
"error": "The exact-action approval did not match this tool request.",
|
||||||
|
"exit_code": 1,
|
||||||
|
"blocked": True,
|
||||||
|
"policy": "exact_tool_approval",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed:
|
||||||
|
decision = security_context.decision_for(
|
||||||
|
getattr(block, "tool_type", None),
|
||||||
|
getattr(block, "content", None),
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
logger.warning(
|
||||||
|
"External-context policy blocked tool=%r",
|
||||||
|
getattr(block, "tool_type", None),
|
||||||
|
)
|
||||||
|
return blocked_tool_result(
|
||||||
|
getattr(block, "tool_type", None),
|
||||||
|
decision.reason or "Tool blocked by external-context policy.",
|
||||||
|
)
|
||||||
|
|
||||||
token = _active_workspace.set(workspace or None)
|
token = _active_workspace.set(workspace or None)
|
||||||
try:
|
try:
|
||||||
output = await _execute_tool_block_impl(
|
output = await _execute_tool_block_impl(
|
||||||
@@ -591,7 +718,28 @@ async def execute_tool_block(
|
|||||||
owner=owner,
|
owner=owner,
|
||||||
progress_cb=progress_cb,
|
progress_cb=progress_cb,
|
||||||
tool_policy=tool_policy,
|
tool_policy=tool_policy,
|
||||||
|
approved_document_id=(
|
||||||
|
exact_approval.pending.document_id
|
||||||
|
if approval_claimed
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
approved_document_version=(
|
||||||
|
exact_approval.pending.document_version
|
||||||
|
if approval_claimed
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
approved_document_digest=(
|
||||||
|
exact_approval.pending.document_digest
|
||||||
|
if approval_claimed
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if isinstance(security_context, ToolRunSecurityContext):
|
||||||
|
security_context.observe_tool_result(
|
||||||
|
getattr(block, "tool_type", None),
|
||||||
|
output[1],
|
||||||
|
getattr(block, "content", None),
|
||||||
|
)
|
||||||
return output
|
return output
|
||||||
finally:
|
finally:
|
||||||
_active_workspace.reset(token)
|
_active_workspace.reset(token)
|
||||||
@@ -604,6 +752,9 @@ async def _execute_tool_block_impl(
|
|||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||||
tool_policy: Optional[Any] = None,
|
tool_policy: Optional[Any] = None,
|
||||||
|
approved_document_id: Optional[str] = None,
|
||||||
|
approved_document_version: Optional[int] = None,
|
||||||
|
approved_document_digest: Optional[str] = None,
|
||||||
) -> Tuple[str, Dict]:
|
) -> Tuple[str, Dict]:
|
||||||
"""Execute a single tool block. Returns (description, result_dict).
|
"""Execute a single tool block. Returns (description, result_dict).
|
||||||
|
|
||||||
@@ -765,7 +916,15 @@ async def _execute_tool_block_impl(
|
|||||||
elif tool in ("create_document", "update_document", "edit_document",
|
elif tool in ("create_document", "update_document", "edit_document",
|
||||||
"suggest_document", "manage_documents"):
|
"suggest_document", "manage_documents"):
|
||||||
desc = f"{tool}: {content.split(chr(10))[0][:80]}"
|
desc = f"{tool}: {content.split(chr(10))[0][:80]}"
|
||||||
result = await _document_tool_dispatch(tool, content, session_id, owner) \
|
result = await _document_tool_dispatch(
|
||||||
|
tool,
|
||||||
|
content,
|
||||||
|
session_id,
|
||||||
|
owner,
|
||||||
|
document_id=approved_document_id,
|
||||||
|
document_version=approved_document_version,
|
||||||
|
document_digest=approved_document_digest,
|
||||||
|
) \
|
||||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||||
if tool in ("edit_document", "suggest_document") and "title" in (result or {}):
|
if tool in ("edit_document", "suggest_document") and "title" in (result or {}):
|
||||||
desc = f"{tool}: {result.get('title', '')}"
|
desc = f"{tool}: {result.get('title', '')}"
|
||||||
|
|||||||
+10
-2
@@ -954,7 +954,11 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
|
|||||||
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
|
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
|
||||||
json={"command": cmd}, headers=headers)
|
json={"command": cmd}, headers=headers)
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
|
return {
|
||||||
|
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
|
||||||
|
"exit_code": 1,
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1083,7 +1087,11 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
|
|||||||
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
|
resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
|
||||||
json={"command": cmd}, headers=headers)
|
json={"command": cmd}, headers=headers)
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
|
return {
|
||||||
|
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
|
||||||
|
"exit_code": 1,
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
data = resp.json() if resp.content else {}
|
data = resp.json() if resp.content else {}
|
||||||
output_text = (data.get("stdout") or "").strip()
|
output_text = (data.get("stdout") or "").strip()
|
||||||
stderr_text = (data.get("stderr") or "").strip()
|
stderr_text = (data.get("stderr") or "").strip()
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ async def do_trigger_research(content: str, owner: Optional[str] = None) -> Dict
|
|||||||
resp = await client.post(f"{_INTERNAL_BASE}/api/research/start",
|
resp = await client.post(f"{_INTERNAL_BASE}/api/research/start",
|
||||||
json=payload, headers=_internal_headers(owner))
|
json=payload, headers=_internal_headers(owner))
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
return {"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
|
return {
|
||||||
|
"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}",
|
||||||
|
"exit_code": 1,
|
||||||
|
"untrusted_content": True,
|
||||||
|
}
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
sid = data.get("session_id", "?")
|
sid = data.get("session_id", "?")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -725,6 +725,7 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
"status_code": resp.status_code,
|
"status_code": resp.status_code,
|
||||||
"body": preview,
|
"body": preview,
|
||||||
"exit_code": 1,
|
"exit_code": 1,
|
||||||
|
"untrusted_content": True,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"output": f"{method} {path} -> {resp.status_code}\n{preview}",
|
"output": f"{method} {path} -> {resp.status_code}\n{preview}",
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ fi
|
|||||||
# values (APP_PORT / APP_BIND), then built-in defaults.
|
# values (APP_PORT / APP_BIND), then built-in defaults.
|
||||||
PORT="${ODYSSEUS_PORT:-${APP_PORT:-7860}}" # 7860, not 7000 — macOS AirPlay Receiver holds 7000.
|
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.
|
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"
|
PROBE_HOST="$HOST"
|
||||||
if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then
|
if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then
|
||||||
PROBE_HOST="127.0.0.1"
|
PROBE_HOST="127.0.0.1"
|
||||||
|
|||||||
+11
-12
@@ -10,9 +10,9 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
|
|||||||
import ragModule from './js/rag.js';
|
import ragModule from './js/rag.js';
|
||||||
import presetsModule from './js/presets.js';
|
import presetsModule from './js/presets.js';
|
||||||
import searchModule from './js/search.js';
|
import searchModule from './js/search.js';
|
||||||
import chatModule from './js/chat.js?v=20260801fix1';
|
import chatModule from './js/chat.js?v=20260815toolapproval4';
|
||||||
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||||
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
import documentModule from './js/document.js?v=20260815approvalsave1';
|
||||||
import searchChatModule from './js/search-chat.js';
|
import searchChatModule from './js/search-chat.js';
|
||||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
settleSessionHydration
|
settleSessionHydration
|
||||||
} from './js/startupShell.js';
|
} from './js/startupShell.js';
|
||||||
import markdownModule from './js/markdown.js';
|
import markdownModule from './js/markdown.js';
|
||||||
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
|
||||||
import sessionModule from './js/sessions.js';
|
import sessionModule from './js/sessions.js';
|
||||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||||
@@ -33,7 +33,7 @@ import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
|||||||
import calendarModule from './js/calendar.js';
|
import calendarModule from './js/calendar.js';
|
||||||
import notesModule from './js/notes.js';
|
import notesModule from './js/notes.js';
|
||||||
import adminModule from './js/admin.js?v=20260716openrouter3';
|
import adminModule from './js/admin.js?v=20260716openrouter3';
|
||||||
import settingsModule from './js/settings.js?v=20260722emailfastindex1';
|
import settingsModule from './js/settings.js?v=20260815approvalsave1';
|
||||||
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
||||||
import './js/modalManager.js?v=20260723compareicon2';
|
import './js/modalManager.js?v=20260723compareicon2';
|
||||||
// Desktop window tiling — drag a modal near an edge/corner to snap.
|
// Desktop window tiling — drag a modal near an edge/corner to snap.
|
||||||
@@ -50,6 +50,7 @@ import * as researchPanelModule from './js/research/panel.js?v=20260630researcht
|
|||||||
import ttsModule from './js/tts-ai.js';
|
import ttsModule from './js/tts-ai.js';
|
||||||
import spinnerModule from './js/spinner.js';
|
import spinnerModule from './js/spinner.js';
|
||||||
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.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 { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js?v=20260715startupclean';
|
||||||
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
|
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
|
||||||
|
|
||||||
@@ -1518,13 +1519,11 @@ function initializeEventListeners() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Hide Gallery when image generation is disabled in settings
|
// Hide Gallery when image generation is disabled in settings.
|
||||||
const _prefetchedSettings = sessionStorage.getItem('ody-prefetch-settings');
|
// getSettings() consumes the login prefetch itself, so every other module
|
||||||
sessionStorage.removeItem('ody-prefetch-settings');
|
// that asks for settings this load gets the same snapshot without a request.
|
||||||
window._initSettingsReady = (_prefetchedSettings
|
window._initSettingsReady = getSettings()
|
||||||
? Promise.resolve(JSON.parse(_prefetchedSettings))
|
.then(settings => {
|
||||||
: fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' }).then(r => r.json())
|
|
||||||
).then(settings => {
|
|
||||||
// NOTE: image_gen_enabled only governs *generating* images in chat — the
|
// NOTE: image_gen_enabled only governs *generating* images in chat — the
|
||||||
// tool is blocked server-side (chat_routes / agent_loop). The Gallery
|
// tool is blocked server-side (chat_routes / agent_loop). The Gallery
|
||||||
// holds uploads and past images too, so it stays visible regardless;
|
// holds uploads and past images too, so it stays visible regardless;
|
||||||
@@ -3705,7 +3704,7 @@ function startOdysseusApp() {
|
|||||||
modelsModule.init(API_BASE);
|
modelsModule.init(API_BASE);
|
||||||
ragModule.init(API_BASE);
|
ragModule.init(API_BASE);
|
||||||
presetsModule.init(API_BASE);
|
presetsModule.init(API_BASE);
|
||||||
searchModule.init(API_BASE);
|
searchModule.init();
|
||||||
chatModule.init(API_BASE);
|
chatModule.init(API_BASE);
|
||||||
chatModule.initListeners();
|
chatModule.initListeners();
|
||||||
groupModule.init(API_BASE);
|
groupModule.init(API_BASE);
|
||||||
|
|||||||
+59
-16
@@ -258,8 +258,8 @@
|
|||||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
|
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
|
||||||
<link rel="modulepreload" href="/static/app.js?v=20260808startupshell1">
|
<link rel="modulepreload" href="/static/app.js?v=20260815toolapproval4">
|
||||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
|
<link rel="modulepreload" href="/static/js/chat.js?v=20260815toolapproval4">
|
||||||
<link rel="modulepreload" href="/static/js/ui.js">
|
<link rel="modulepreload" href="/static/js/ui.js">
|
||||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||||
<link rel="modulepreload" href="/static/js/markdown.js">
|
<link rel="modulepreload" href="/static/js/markdown.js">
|
||||||
@@ -1421,6 +1421,55 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
<div class="settings-sidebar">
|
<div class="settings-sidebar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-sidebar-toggle"
|
||||||
|
id="settings-sidebar-toggle"
|
||||||
|
aria-label="Collapse settings navigation"
|
||||||
|
title="Collapse settings navigation"
|
||||||
|
>
|
||||||
|
<svg class="settings-sidebar-toggle-collapse" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 18 9 12 15 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
<svg class="settings-sidebar-toggle-expand" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="settings-sidebar-resize-handle"
|
||||||
|
id="settings-sidebar-resize-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize settings navigation"
|
||||||
|
aria-valuemin="150"
|
||||||
|
aria-valuemax="340"
|
||||||
|
aria-valuenow="220"
|
||||||
|
tabindex="0"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="settings-sidebar-content">
|
||||||
|
<div class="settings-nav-search-wrap">
|
||||||
|
<svg class="settings-nav-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="7"></circle>
|
||||||
|
<path d="M20 20l-4-4"></path>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="settings-nav-search"
|
||||||
|
class="settings-nav-search"
|
||||||
|
placeholder="Find settings…"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="Find settings"
|
||||||
|
aria-controls="settings-nav-search-results"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="settings-nav-search-results"
|
||||||
|
class="settings-nav-search-results hidden"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Settings search results"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
|
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
|
||||||
<button class="settings-nav-item active" data-settings-tab="services">
|
<button class="settings-nav-item active" data-settings-tab="services">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||||
@@ -1481,9 +1530,10 @@
|
|||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="settings-nav-item admin-only" data-settings-tab="system">
|
<button class="settings-nav-item admin-only" data-settings-tab="system">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v-.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"/></svg>
|
||||||
<span>System</span>
|
<span>System</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-panels">
|
<div class="settings-panels">
|
||||||
|
|
||||||
@@ -1504,13 +1554,6 @@
|
|||||||
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||||
<select id="set-defaultModelSelect" class="settings-select"></select>
|
<select id="set-defaultModelSelect" class="settings-select"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row" style="align-items:flex-start;" hidden>
|
|
||||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
|
||||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
|
||||||
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
|
|
||||||
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2539,20 +2582,20 @@
|
|||||||
<script type="module" src="/static/js/search.js"></script>
|
<script type="module" src="/static/js/search.js"></script>
|
||||||
<script type="module" src="/static/js/spinner.js"></script>
|
<script type="module" src="/static/js/spinner.js"></script>
|
||||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||||
<script type="module" src="/static/js/document.js?v=20260722emailfastindex1"></script>
|
<script type="module" src="/static/js/document.js?v=20260815approvalsave1"></script>
|
||||||
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
|
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
|
||||||
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
|
<script type="module" src="/static/js/chatRenderer.js?v=20260815toolapproval4"></script>
|
||||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||||
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
|
<script type="module" src="/static/js/chatStream.js?v=20260815approvalsave1"></script>
|
||||||
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
|
<script type="module" src="/static/js/chat.js?v=20260815toolapproval4"></script>
|
||||||
<script type="module" src="/static/js/cookbook.js"></script>
|
<script type="module" src="/static/js/cookbook.js"></script>
|
||||||
<script src="/static/js/cookbookSchedule.js"></script>
|
<script src="/static/js/cookbookSchedule.js"></script>
|
||||||
<script type="module" src="/static/js/search-chat.js"></script>
|
<script type="module" src="/static/js/search-chat.js"></script>
|
||||||
<script type="module" src="/static/js/theme.js"></script>
|
<script type="module" src="/static/js/theme.js"></script>
|
||||||
<script type="module" src="/static/js/censor.js"></script>
|
<script type="module" src="/static/js/censor.js"></script>
|
||||||
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
|
<script type="module" src="/static/js/settings.js?v=20260815approvalsave1"></script>
|
||||||
<script type="module" src="/static/js/assistant.js"></script>
|
<script type="module" src="/static/js/assistant.js"></script>
|
||||||
<script type="module" src="/static/app.js?v=20260808startupshell1"></script> <!-- app.js must be LAST -->
|
<script type="module" src="/static/app.js?v=20260815toolapproval4"></script> <!-- app.js must be LAST -->
|
||||||
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
||||||
<script type="module" src="/static/js/a11y.js"></script>
|
<script type="module" src="/static/js/a11y.js"></script>
|
||||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||||
|
|||||||
+65
-17
@@ -6,6 +6,7 @@ import settingsModule from './settings.js';
|
|||||||
import { providerLogo, providerLogoFromUrl } from './providers.js';
|
import { providerLogo, providerLogoFromUrl } from './providers.js';
|
||||||
import { sortModelObjects } from './modelSort.js';
|
import { sortModelObjects } from './modelSort.js';
|
||||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||||
|
import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
@@ -345,8 +346,7 @@ function initSignupToggle() {
|
|||||||
|
|
||||||
function initShareDefaultsToggle() {
|
function initShareDefaultsToggle() {
|
||||||
const toggle = el('adm-shareDefaultsToggle');
|
const toggle = el('adm-shareDefaultsToggle');
|
||||||
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
getSettings()
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
|
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
|
||||||
.catch(e => console.warn('Settings fetch failed:', e));
|
.catch(e => console.warn('Settings fetch failed:', e));
|
||||||
toggle.addEventListener('change', async () => {
|
toggle.addEventListener('change', async () => {
|
||||||
@@ -361,6 +361,9 @@ function initShareDefaultsToggle() {
|
|||||||
toggle.checked = !!data.share_defaults_with_users;
|
toggle.checked = !!data.share_defaults_with_users;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toggle.checked = !toggle.checked;
|
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');
|
const list = el('adm-builtin-tools-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tools', { credentials: 'same-origin' });
|
// This panel is an editor, and its save posts the whole disabled list
|
||||||
const data = await res.json();
|
// 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 || [];
|
const tools = data.tools || [];
|
||||||
if (!tools.length) { list.innerHTML = '<div class="admin-empty">No tools found</div>'; return; }
|
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
|
// Merge only the user's intended changes onto authoritative server state.
|
||||||
async function _saveToolState() {
|
// /api/tools replaces the full disabled list, so rebuilding it from this
|
||||||
const allChecks = list.querySelectorAll('input[data-tool-id]');
|
// panel's DOM can undo a change made by another tab or manage_settings
|
||||||
const disabled = [];
|
// after the panel was opened.
|
||||||
allChecks.forEach(c => { if (!c.checked) disabled.push(c.dataset.toolId); });
|
async function _saveToolState(changes) {
|
||||||
await fetch('/api/tools', {
|
invalidateTools();
|
||||||
method: 'POST',
|
const latest = await getTools();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const state = new Map(
|
||||||
body: JSON.stringify({ disabled }),
|
(latest.tools || []).map(t => [t.id, !!t.enabled])
|
||||||
credentials: 'same-origin',
|
);
|
||||||
});
|
|
||||||
|
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) {
|
function _updateCatCounter(catEl) {
|
||||||
if (!catEl) return;
|
if (!catEl) return;
|
||||||
@@ -1993,7 +2037,9 @@ async function loadBuiltinTools() {
|
|||||||
// Wire individual tool toggles
|
// Wire individual tool toggles
|
||||||
list.querySelectorAll('input[data-tool-id]').forEach(chk => {
|
list.querySelectorAll('input[data-tool-id]').forEach(chk => {
|
||||||
chk.addEventListener('change', async () => {
|
chk.addEventListener('change', async () => {
|
||||||
await _saveToolState();
|
await _saveToolState([
|
||||||
|
{ id: chk.dataset.toolId, enabled: chk.checked },
|
||||||
|
]);
|
||||||
_updateCatCounter(chk.closest('.admin-tool-category'));
|
_updateCatCounter(chk.closest('.admin-tool-category'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2004,8 +2050,10 @@ async function loadBuiltinTools() {
|
|||||||
const catEl = chk.closest('.admin-tool-category');
|
const catEl = chk.closest('.admin-tool-category');
|
||||||
if (!catEl) return;
|
if (!catEl) return;
|
||||||
const checked = chk.checked;
|
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; });
|
catEl.querySelectorAll('input[data-tool-id]').forEach(c => { c.checked = checked; });
|
||||||
await _saveToolState();
|
await _saveToolState(changes);
|
||||||
_updateCatCounter(catEl);
|
_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;
|
||||||
|
}
|
||||||
+639
-194
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
|||||||
|
/** Select and update the response holder for a route-provenance event. */
|
||||||
|
export function applyModelRouteEventState(event, holder, roundHolder, defaultModel = '') {
|
||||||
|
const target = event && event.round && roundHolder ? roundHolder : holder;
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
target._requestedModel = (
|
||||||
|
event.requested_model
|
||||||
|
|| event.selected_model
|
||||||
|
|| target._requestedModel
|
||||||
|
|| defaultModel
|
||||||
|
);
|
||||||
|
target._actualModel = (
|
||||||
|
event.model
|
||||||
|
|| event.answered_by
|
||||||
|
|| target._actualModel
|
||||||
|
|| target._requestedModel
|
||||||
|
);
|
||||||
|
const hasEndpointRoute = Boolean(
|
||||||
|
event.requested_endpoint_id
|
||||||
|
|| event.selected_endpoint_id
|
||||||
|
|| event.endpoint_id
|
||||||
|
|| event.answered_by_endpoint_id
|
||||||
|
|| event.requested_endpoint_label
|
||||||
|
|| event.selected_endpoint_label
|
||||||
|
|| event.endpoint_label
|
||||||
|
|| event.answered_by_endpoint_label
|
||||||
|
|| target._requestedEndpointLabel
|
||||||
|
);
|
||||||
|
if (hasEndpointRoute) {
|
||||||
|
target._requestedEndpointId = (
|
||||||
|
event.requested_endpoint_id
|
||||||
|
|| event.selected_endpoint_id
|
||||||
|
|| target._requestedEndpointId
|
||||||
|
|| null
|
||||||
|
);
|
||||||
|
target._requestedEndpointLabel = (
|
||||||
|
event.requested_endpoint_label
|
||||||
|
|| event.selected_endpoint_label
|
||||||
|
|| target._requestedEndpointLabel
|
||||||
|
|| 'Selected route'
|
||||||
|
);
|
||||||
|
target._actualEndpointId = (
|
||||||
|
event.endpoint_id
|
||||||
|
|| event.answered_by_endpoint_id
|
||||||
|
|| target._actualEndpointId
|
||||||
|
|| target._requestedEndpointId
|
||||||
|
|| null
|
||||||
|
);
|
||||||
|
target._actualEndpointLabel = (
|
||||||
|
event.endpoint_label
|
||||||
|
|| event.answered_by_endpoint_label
|
||||||
|
|| target._actualEndpointLabel
|
||||||
|
|| target._requestedEndpointLabel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy the active route into the bubble created for the next Agent round. */
|
||||||
|
export function inheritModelRouteState(holder, roundHolder, target, defaultModel = '') {
|
||||||
|
if (!target) return null;
|
||||||
|
const source = roundHolder || holder;
|
||||||
|
target._requestedModel = source?._requestedModel || defaultModel;
|
||||||
|
target._actualModel = source?._actualModel || target._requestedModel;
|
||||||
|
if (source?._requestedEndpointLabel || source?._actualEndpointLabel) {
|
||||||
|
target._requestedEndpointId = source?._requestedEndpointId || null;
|
||||||
|
target._requestedEndpointLabel = source?._requestedEndpointLabel || 'Selected route';
|
||||||
|
target._actualEndpointId = source?._actualEndpointId || target._requestedEndpointId;
|
||||||
|
target._actualEndpointLabel = source?._actualEndpointLabel || target._requestedEndpointLabel;
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply final/metrics provenance to the active round, not the first bubble. */
|
||||||
|
export function applyModelMetricsState(metrics, holder, roundHolder, defaultModel = '') {
|
||||||
|
const target = roundHolder || holder;
|
||||||
|
if (!target || !metrics) return target || null;
|
||||||
|
const roundModels = Array.isArray(metrics.round_models) ? metrics.round_models : [];
|
||||||
|
const roundModel = roundHolder && roundModels.length
|
||||||
|
? roundModels[roundModels.length - 1]
|
||||||
|
: null;
|
||||||
|
target._requestedModel = metrics.requested_model || target._requestedModel || defaultModel;
|
||||||
|
target._actualModel = roundModel || metrics.model || target._actualModel || target._requestedModel;
|
||||||
|
const roundEndpointIds = Array.isArray(metrics.round_endpoint_ids) ? metrics.round_endpoint_ids : [];
|
||||||
|
const roundEndpointLabels = Array.isArray(metrics.round_endpoint_labels) ? metrics.round_endpoint_labels : [];
|
||||||
|
if (
|
||||||
|
metrics.requested_endpoint_label
|
||||||
|
|| metrics.endpoint_label
|
||||||
|
|| roundEndpointLabels.length
|
||||||
|
|| target._requestedEndpointLabel
|
||||||
|
) {
|
||||||
|
target._requestedEndpointId = metrics.requested_endpoint_id || target._requestedEndpointId || null;
|
||||||
|
target._requestedEndpointLabel = metrics.requested_endpoint_label || target._requestedEndpointLabel || 'Selected route';
|
||||||
|
const hasRoundEndpointId = Boolean(roundHolder && roundEndpointIds.length);
|
||||||
|
const hasRoundEndpointLabel = Boolean(roundHolder && roundEndpointLabels.length);
|
||||||
|
target._actualEndpointId = hasRoundEndpointId
|
||||||
|
? roundEndpointIds[roundEndpointIds.length - 1]
|
||||||
|
: (metrics.endpoint_id || target._actualEndpointId || target._requestedEndpointId);
|
||||||
|
target._actualEndpointLabel = hasRoundEndpointLabel
|
||||||
|
? roundEndpointLabels[roundEndpointLabels.length - 1]
|
||||||
|
: (metrics.endpoint_label || target._actualEndpointLabel || target._requestedEndpointLabel);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
+312
-57
@@ -9,7 +9,9 @@ import { providerLogo, providerLabel } from './providers.js';
|
|||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { bindMenuDismiss } from './escMenuStack.js';
|
import { bindMenuDismiss } from './escMenuStack.js';
|
||||||
|
import { loadPanel } from './panels.js';
|
||||||
import { matchModelKey } from './model/matchKey.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 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>';
|
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() {
|
async function loadExecFenceRegex() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tools', { credentials: 'same-origin' });
|
// Shared with admin.js, and — more to the point — with the other copies of
|
||||||
const data = await res.json();
|
// 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 || [])
|
const tags = (data.tools || [])
|
||||||
.map((t) => t.id)
|
.map((t) => t.id)
|
||||||
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
|
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
|
||||||
@@ -615,10 +621,36 @@ export function sameModelName(left, right) {
|
|||||||
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
|
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function modelRouteLabel(requestedModel, actualModel) {
|
function shortEndpointLabel(label) {
|
||||||
|
const value = modelValue(label);
|
||||||
|
if (!value) return '';
|
||||||
|
return value.length > 18 ? value.slice(0, 17) + '…' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelRouteLabel(
|
||||||
|
requestedModel,
|
||||||
|
actualModel,
|
||||||
|
requestedEndpointLabel = '',
|
||||||
|
actualEndpointLabel = '',
|
||||||
|
requestedEndpointId = '',
|
||||||
|
actualEndpointId = '',
|
||||||
|
) {
|
||||||
const requested = modelValue(requestedModel);
|
const requested = modelValue(requestedModel);
|
||||||
const actual = modelValue(actualModel) || requested;
|
const actual = modelValue(actualModel) || requested;
|
||||||
if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested);
|
const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel);
|
||||||
|
const actualRoute = modelValue(actualEndpointId || actualEndpointLabel);
|
||||||
|
const routeChanged = Boolean(
|
||||||
|
actualRoute
|
||||||
|
&& requestedRoute
|
||||||
|
&& actualRoute !== requestedRoute
|
||||||
|
);
|
||||||
|
if (!requested || sameModelName(requested, actual)) {
|
||||||
|
const model = shortModel(actual || requested);
|
||||||
|
if (!routeChanged) return model;
|
||||||
|
const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route');
|
||||||
|
const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId);
|
||||||
|
return model + ' (' + from + ' -> ' + to + ')';
|
||||||
|
}
|
||||||
return shortModel(requested) + ' -> ' + shortModel(actual);
|
return shortModel(requested) + ' -> ' + shortModel(actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,10 +661,24 @@ export function replyModelPair(modelName, metadata) {
|
|||||||
if (actualFromMeta || requestedFromMeta) {
|
if (actualFromMeta || requestedFromMeta) {
|
||||||
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
|
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
|
||||||
const requested = requestedFromMeta || actual;
|
const requested = requestedFromMeta || actual;
|
||||||
return { requestedModel: requested, actualModel: actual };
|
return {
|
||||||
|
requestedModel: requested,
|
||||||
|
actualModel: actual,
|
||||||
|
requestedEndpointId: meta.requested_endpoint_id || null,
|
||||||
|
requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route',
|
||||||
|
actualEndpointId: meta.endpoint_id || null,
|
||||||
|
actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const fallback = modelValue(modelName);
|
const fallback = modelValue(modelName);
|
||||||
return { requestedModel: fallback, actualModel: fallback };
|
return {
|
||||||
|
requestedModel: fallback,
|
||||||
|
actualModel: fallback,
|
||||||
|
requestedEndpointId: null,
|
||||||
|
requestedEndpointLabel: 'Selected route',
|
||||||
|
actualEndpointId: null,
|
||||||
|
actualEndpointLabel: 'Selected route',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -824,12 +870,50 @@ export function isCostTrackedEndpoint(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Cost for the current turn, returning null for non-billable endpoints. */
|
/** Cost for the current turn, returning null for non-billable endpoints. */
|
||||||
function _billableCost(model, inputTokens, outputTokens) {
|
function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) {
|
||||||
const url = _currentEndpointUrl();
|
// Foreground fallback can answer on a different endpoint than the session's
|
||||||
if (!isCostTrackedEndpoint(url)) return null;
|
// selected route. Prefer the backend's non-secret actual-route
|
||||||
|
// classification; retain the selected-endpoint check for older history.
|
||||||
|
if (endpointCostTracked === false) return null;
|
||||||
|
const selectedUrl = selectedEndpointUrl === undefined
|
||||||
|
? _currentEndpointUrl()
|
||||||
|
: selectedEndpointUrl;
|
||||||
|
if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return getModelCost(model, inputTokens, outputTokens);
|
return getModelCost(model, inputTokens, outputTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sum cost using the route/model that produced each Agent round. */
|
||||||
|
function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) {
|
||||||
|
const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : [];
|
||||||
|
if (!buckets.length) {
|
||||||
|
return _billableCost(
|
||||||
|
model,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
metrics.endpoint_cost_tracked,
|
||||||
|
selectedEndpointUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let total = 0;
|
||||||
|
let hasPricedUsage = false;
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
if (!bucket || typeof bucket !== 'object') continue;
|
||||||
|
const bucketCost = _billableCost(
|
||||||
|
bucket.model || model,
|
||||||
|
Number(bucket.input_tokens) || 0,
|
||||||
|
Number(bucket.output_tokens) || 0,
|
||||||
|
bucket.endpoint_cost_tracked,
|
||||||
|
selectedEndpointUrl,
|
||||||
|
);
|
||||||
|
if (bucketCost === null) continue;
|
||||||
|
total += bucketCost;
|
||||||
|
hasPricedUsage = true;
|
||||||
|
}
|
||||||
|
return hasPricedUsage ? total : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getImageCost(model, quality, size) {
|
export function getImageCost(model, quality, size) {
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
const m = model.toLowerCase();
|
const m = model.toLowerCase();
|
||||||
@@ -844,6 +928,9 @@ export function getImageCost(model, quality, size) {
|
|||||||
|
|
||||||
/* ── Session cost helpers ─────────────────────────────────────────── */
|
/* ── Session cost helpers ─────────────────────────────────────────── */
|
||||||
const _COST_KEY = 'ody-session-cost';
|
const _COST_KEY = 'ody-session-cost';
|
||||||
|
const _COST_RUNS_KEY = 'ody-session-cost-runs';
|
||||||
|
const _MAX_COST_RUNS_PER_SESSION = 256;
|
||||||
|
const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger';
|
||||||
|
|
||||||
/** Return the accumulated cost for the current (or given) session. */
|
/** Return the accumulated cost for the current (or given) session. */
|
||||||
export function getSessionCost(sessionId) {
|
export function getSessionCost(sessionId) {
|
||||||
@@ -851,7 +938,14 @@ export function getSessionCost(sessionId) {
|
|||||||
if (!sid) return 0;
|
if (!sid) return 0;
|
||||||
try {
|
try {
|
||||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||||
return costs[sid] || 0;
|
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||||
|
const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
|
||||||
|
? Object.values(runCosts[sid])
|
||||||
|
: [];
|
||||||
|
return (costs[sid] || 0) + recordedRuns.reduce(
|
||||||
|
(total, value) => total + (Number(value) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
} catch (_e) { return 0; }
|
} catch (_e) { return 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,6 +957,9 @@ export function resetSessionCost(sessionId) {
|
|||||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||||
delete costs[sid];
|
delete costs[sid];
|
||||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||||
|
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||||
|
delete runCosts[sid];
|
||||||
|
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
|
||||||
} catch (_e) { /* ignore */ }
|
} catch (_e) { /* ignore */ }
|
||||||
updateSessionCostUI();
|
updateSessionCostUI();
|
||||||
}
|
}
|
||||||
@@ -871,21 +968,8 @@ export function resetSessionCost(sessionId) {
|
|||||||
export function updateSessionCostUI() {
|
export function updateSessionCostUI() {
|
||||||
const el = document.getElementById('session-cost-display');
|
const el = document.getElementById('session-cost-display');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
|
// The ledger records billable work already performed in this session. A
|
||||||
// cloud-rate calculation may have left in localStorage for this session.
|
// selected local endpoint does not erase cost from a paid fallback route.
|
||||||
const _url = _currentEndpointUrl();
|
|
||||||
if (!isCostTrackedEndpoint(_url)) {
|
|
||||||
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
|
||||||
if (sid && getSessionCost(sid) > 0) {
|
|
||||||
try {
|
|
||||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
|
||||||
delete costs[sid];
|
|
||||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
|
||||||
} catch (_e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
el.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cost = getSessionCost();
|
const cost = getSessionCost();
|
||||||
if (cost > 0) {
|
if (cost > 0) {
|
||||||
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
|
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
|
||||||
@@ -895,6 +979,94 @@ export function updateSessionCostUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Record one metrics payload in a session ledger at most once. */
|
||||||
|
export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) {
|
||||||
|
if (!metrics || typeof metrics !== 'object') return null;
|
||||||
|
const cost = _metricsBillableCost(
|
||||||
|
metrics,
|
||||||
|
metrics.model || 'Unknown',
|
||||||
|
metrics.input_tokens || 0,
|
||||||
|
metrics.output_tokens || 0,
|
||||||
|
selectedEndpointUrl,
|
||||||
|
);
|
||||||
|
if (metrics._fromHistory) return cost;
|
||||||
|
const sid = sessionId || (
|
||||||
|
window.sessionModule && window.sessionModule.getCurrentSessionId()
|
||||||
|
);
|
||||||
|
if (!sid || cost === null) return cost;
|
||||||
|
const runId = typeof metrics._costRecordId === 'string'
|
||||||
|
? metrics._costRecordId.trim()
|
||||||
|
: '';
|
||||||
|
if ((metrics._costRecorded || metrics._costRecordPending) && !runId) return cost;
|
||||||
|
// Recorded is only set once the write actually runs; pending covers the
|
||||||
|
// window while the write waits on the cross-tab lock, so a replay in that
|
||||||
|
// window cannot double-add and a tab closed mid-queue never claims recorded.
|
||||||
|
metrics._costRecordPending = true;
|
||||||
|
const writeCost = () => {
|
||||||
|
if (runId) {
|
||||||
|
try {
|
||||||
|
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||||
|
const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
|
||||||
|
? runCosts[sid]
|
||||||
|
: {};
|
||||||
|
// Assigning by detached-run identity is replay-idempotent even when a
|
||||||
|
// refresh produces a fresh metrics object. The Web Lock around this
|
||||||
|
// read/modify/write also keeps distinct runs from two tabs from
|
||||||
|
// overwriting one another's stale snapshot.
|
||||||
|
sessionRuns[runId] = cost;
|
||||||
|
const entries = Object.entries(sessionRuns);
|
||||||
|
if (entries.length > _MAX_COST_RUNS_PER_SESSION) {
|
||||||
|
const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION);
|
||||||
|
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||||
|
costs[sid] = (costs[sid] || 0) + overflow.reduce(
|
||||||
|
(total, entry) => total + (Number(entry[1]) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]);
|
||||||
|
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||||
|
}
|
||||||
|
runCosts[sid] = sessionRuns;
|
||||||
|
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
|
||||||
|
} catch (_e) { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||||
|
costs[sid] = (costs[sid] || 0) + cost;
|
||||||
|
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||||
|
} catch (_e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
metrics._costRecorded = true;
|
||||||
|
metrics._costRecordPending = false;
|
||||||
|
const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||||
|
if (currentSid === sid) updateSessionCostUI();
|
||||||
|
};
|
||||||
|
|
||||||
|
let writeStarted = false;
|
||||||
|
const guardedWrite = () => {
|
||||||
|
writeStarted = true;
|
||||||
|
writeCost();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof navigator !== 'undefined'
|
||||||
|
&& navigator.locks
|
||||||
|
&& typeof navigator.locks.request === 'function'
|
||||||
|
) {
|
||||||
|
const pendingWrite = navigator.locks.request(_COST_LEDGER_LOCK, guardedWrite);
|
||||||
|
if (pendingWrite && typeof pendingWrite.catch === 'function') {
|
||||||
|
pendingWrite.catch(() => {
|
||||||
|
if (!writeStarted) guardedWrite();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
guardedWrite();
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
if (!writeStarted) guardedWrite();
|
||||||
|
}
|
||||||
|
return cost;
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a timestamp span for role labels.
|
/** Create a timestamp span for role labels.
|
||||||
* Pass an ISO string / Date / epoch-ms to render the message's own time
|
* Pass an ISO string / Date / epoch-ms to render the message's own time
|
||||||
* (used when replaying history). Falls back to "now" when no value is given. */
|
* (used when replaying history). Falls back to "now" when no value is given. */
|
||||||
@@ -1201,7 +1373,7 @@ document.addEventListener('click', function(e) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
} else if (kind === 'document') {
|
} else if (kind === 'document') {
|
||||||
import('./document.js?v=20260722emailfastindex1').then(mod => {
|
import('./document.js?v=20260815approvalsave1').then(mod => {
|
||||||
const open = mod.loadDocument
|
const open = mod.loadDocument
|
||||||
|| mod.openDocument
|
|| mod.openDocument
|
||||||
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
|
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
|
||||||
@@ -1223,7 +1395,7 @@ document.addEventListener('click', function(e) {
|
|||||||
if (open) open(id);
|
if (open) open(id);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else if (kind === 'email') {
|
} else if (kind === 'email') {
|
||||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
|
import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
|
||||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||||
if (open) open({ uid: id });
|
if (open) open({ uid: id });
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@@ -1382,7 +1554,7 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
|||||||
try {
|
try {
|
||||||
const [galleryMod, editorMod] = await Promise.all([
|
const [galleryMod, editorMod] = await Promise.all([
|
||||||
import('./gallery.js'),
|
import('./gallery.js'),
|
||||||
import('./galleryEditor.js'),
|
loadPanel('editor'),
|
||||||
]);
|
]);
|
||||||
// Ensure the Gallery modal is open so the editor has a container
|
// Ensure the Gallery modal is open so the editor has a container
|
||||||
// to render into; switch its tabs to the Edit tab.
|
// to render into; switch its tabs to the Edit tab.
|
||||||
@@ -1874,23 +2046,19 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
const isReal = metrics.usage_source === 'real';
|
const isReal = metrics.usage_source === 'real';
|
||||||
const ctxPct = metrics.context_percent;
|
const ctxPct = metrics.context_percent;
|
||||||
const model = metrics.model || 'Unknown';
|
const model = metrics.model || 'Unknown';
|
||||||
const cost = _billableCost(model, inputTokens, outputTokens);
|
const cost = _metricsBillableCost(
|
||||||
|
metrics,
|
||||||
|
model,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
);
|
||||||
|
|
||||||
// Nothing useful to show — bail out (only if ALL metrics are missing)
|
// Nothing useful to show — bail out (only if ALL metrics are missing)
|
||||||
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
|
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
|
||||||
|
|
||||||
// Accumulate session cost (only on fresh metrics, not history reload)
|
// Rendering can occur when metrics arrive and again after [DONE]. The
|
||||||
if (!metrics._fromHistory) {
|
// ledger mutation is idempotent for that shared payload.
|
||||||
const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
recordSessionMetricsCost(metrics);
|
||||||
if (_sid && cost !== null) {
|
|
||||||
try {
|
|
||||||
const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
|
||||||
_costs[_sid] = (_costs[_sid] || 0) + cost;
|
|
||||||
localStorage.setItem(_COST_KEY, JSON.stringify(_costs));
|
|
||||||
} catch (_e) { /* ignore */ }
|
|
||||||
updateSessionCostUI();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep token counts in the Message Stats popup; the footer should stay slim.
|
// Keep token counts in the Message Stats popup; the footer should stay slim.
|
||||||
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
|
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
|
||||||
@@ -2180,6 +2348,7 @@ export function renderAskUserCard(payload, options) {
|
|||||||
card.setAttribute('role', 'group');
|
card.setAttribute('role', 'group');
|
||||||
card.tabIndex = -1;
|
card.tabIndex = -1;
|
||||||
const multi = !!aq.multi;
|
const multi = !!aq.multi;
|
||||||
|
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
|
||||||
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||||
|
|
||||||
const head = document.createElement('div');
|
const head = document.createElement('div');
|
||||||
@@ -2204,6 +2373,27 @@ export function renderAskUserCard(payload, options) {
|
|||||||
card.appendChild(question);
|
card.appendChild(question);
|
||||||
card.setAttribute('aria-labelledby', question.id);
|
card.setAttribute('aria-labelledby', question.id);
|
||||||
|
|
||||||
|
if (isToolApproval && aq.action) {
|
||||||
|
const action = document.createElement('div');
|
||||||
|
action.className = 'ask-user-option-desc';
|
||||||
|
const effects = Array.isArray(aq.action.effects)
|
||||||
|
? aq.action.effects.join(', ')
|
||||||
|
: '';
|
||||||
|
action.textContent = [
|
||||||
|
aq.action.tool || 'tool',
|
||||||
|
aq.action.content || '',
|
||||||
|
effects ? `Effects: ${effects}` : '',
|
||||||
|
aq.action.workspace ? `Workspace: ${aq.action.workspace}` : '',
|
||||||
|
aq.action.document_id ? `Document: ${aq.action.document_id}` : '',
|
||||||
|
aq.action.document_version != null
|
||||||
|
? `Document version: ${aq.action.document_version}`
|
||||||
|
: '',
|
||||||
|
aq.action.digest ? `Approval fingerprint: ${aq.action.digest}` : '',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
action.style.whiteSpace = 'pre-wrap';
|
||||||
|
card.appendChild(action);
|
||||||
|
}
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'ask-user-options';
|
list.className = 'ask-user-options';
|
||||||
card.appendChild(list);
|
card.appendChild(list);
|
||||||
@@ -2241,7 +2431,23 @@ export function renderAskUserCard(payload, options) {
|
|||||||
}
|
}
|
||||||
if (!multi) {
|
if (!multi) {
|
||||||
row.type = 'button';
|
row.type = 'button';
|
||||||
row.addEventListener('click', () => send(label));
|
row.addEventListener('click', () => {
|
||||||
|
if (isToolApproval) {
|
||||||
|
card.remove();
|
||||||
|
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', {
|
||||||
|
detail: {
|
||||||
|
approval_id: aq.approval_id,
|
||||||
|
decision: String((opt && opt.value) || '').toLowerCase(),
|
||||||
|
label,
|
||||||
|
document_id: aq.action && aq.action.document_id
|
||||||
|
? String(aq.action.document_id)
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
send(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -2277,7 +2483,7 @@ export function renderAskUserCard(payload, options) {
|
|||||||
});
|
});
|
||||||
other.appendChild(otherInput);
|
other.appendChild(otherInput);
|
||||||
other.appendChild(otherSend);
|
other.appendChild(otherSend);
|
||||||
card.appendChild(other);
|
if (!isToolApproval) card.appendChild(other);
|
||||||
|
|
||||||
chatBox.appendChild(card);
|
chatBox.appendChild(card);
|
||||||
if (renderOptions.scroll !== false) {
|
if (renderOptions.scroll !== false) {
|
||||||
@@ -2307,9 +2513,19 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
||||||
|
|
||||||
// --- Agent multi-bubble reconstruction from saved metadata ---
|
// --- Agent multi-bubble reconstruction from saved metadata ---
|
||||||
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
if (
|
||||||
|
role === 'assistant'
|
||||||
|
&& metadata
|
||||||
|
&& (
|
||||||
|
(Array.isArray(metadata.tool_events) && metadata.tool_events.length > 0)
|
||||||
|
|| (Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1)
|
||||||
|
)
|
||||||
|
) {
|
||||||
const roundTexts = metadata.round_texts || [];
|
const roundTexts = metadata.round_texts || [];
|
||||||
const toolEvents = metadata.tool_events;
|
const roundModels = metadata.round_models || [];
|
||||||
|
const roundEndpointIds = metadata.round_endpoint_ids || [];
|
||||||
|
const roundEndpointLabels = metadata.round_endpoint_labels || [];
|
||||||
|
const toolEvents = metadata.tool_events || [];
|
||||||
let pendingAskUser = null;
|
let pendingAskUser = null;
|
||||||
let lastWrap = null;
|
let lastWrap = null;
|
||||||
let firstMsgAi = null;
|
let firstMsgAi = null;
|
||||||
@@ -2317,16 +2533,20 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
|
|
||||||
const toolsByRound = {};
|
const toolsByRound = {};
|
||||||
for (const ev of toolEvents) {
|
for (const ev of toolEvents) {
|
||||||
const r = ev.round || 1;
|
const r = ev.round ?? 1;
|
||||||
if (!toolsByRound[r]) toolsByRound[r] = [];
|
if (!toolsByRound[r]) toolsByRound[r] = [];
|
||||||
toolsByRound[r].push(ev);
|
toolsByRound[r].push(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length);
|
const toolRounds = Object.keys(toolsByRound).map(Number);
|
||||||
|
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
|
||||||
|
|
||||||
for (let r = 0; r < maxRound; r++) {
|
const firstRound = (toolsByRound[0] || []).length ? 0 : 1;
|
||||||
const roundNum = r + 1;
|
for (let roundNum = firstRound; roundNum <= maxRound; roundNum++) {
|
||||||
const txt = resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata);
|
const r = roundNum - 1;
|
||||||
|
const txt = r >= 0
|
||||||
|
? resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata)
|
||||||
|
: '';
|
||||||
|
|
||||||
if (txt) {
|
if (txt) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
@@ -2334,10 +2554,31 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
const roleEl = document.createElement('div');
|
const roleEl = document.createElement('div');
|
||||||
roleEl.className = 'role';
|
roleEl.className = 'role';
|
||||||
const pair = replyModelPair(modelName, metadata);
|
const pair = replyModelPair(modelName, metadata);
|
||||||
const contModel = pair.actualModel || pair.requestedModel;
|
const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;
|
||||||
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
|
const contEndpointId = r < roundEndpointIds.length
|
||||||
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
|
? roundEndpointIds[r]
|
||||||
roleEl.title = pair.requestedModel + ' -> ' + contModel;
|
: pair.actualEndpointId;
|
||||||
|
const contEndpointLabel = r < roundEndpointLabels.length
|
||||||
|
? roundEndpointLabels[r]
|
||||||
|
: pair.actualEndpointLabel;
|
||||||
|
roleEl.textContent = modelRouteLabel(
|
||||||
|
pair.requestedModel,
|
||||||
|
contModel,
|
||||||
|
pair.requestedEndpointLabel,
|
||||||
|
contEndpointLabel,
|
||||||
|
pair.requestedEndpointId,
|
||||||
|
contEndpointId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
pair.requestedModel
|
||||||
|
&& contModel
|
||||||
|
&& (
|
||||||
|
!sameModelName(pair.requestedModel, contModel)
|
||||||
|
|| (pair.requestedEndpointId && contEndpointId && pair.requestedEndpointId !== contEndpointId)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
roleEl.title = pair.requestedModel + ' -> ' + contModel
|
||||||
|
+ ' (' + pair.requestedEndpointLabel + ' -> ' + contEndpointLabel + ')';
|
||||||
}
|
}
|
||||||
applyModelColor(roleEl, contModel);
|
applyModelColor(roleEl, contModel);
|
||||||
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
|
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
|
||||||
@@ -2492,7 +2733,14 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
const isCompacted = metadata?.compacted;
|
const isCompacted = metadata?.compacted;
|
||||||
const replyModels = replyModelPair(modelName, metadata);
|
const replyModels = replyModelPair(modelName, metadata);
|
||||||
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
|
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
|
||||||
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel);
|
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(
|
||||||
|
replyModels.requestedModel,
|
||||||
|
resolvedModel,
|
||||||
|
replyModels.requestedEndpointLabel,
|
||||||
|
replyModels.actualEndpointLabel,
|
||||||
|
replyModels.requestedEndpointId,
|
||||||
|
replyModels.actualEndpointId,
|
||||||
|
);
|
||||||
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
|
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
|
||||||
_roleText += ' (Research)';
|
_roleText += ' (Research)';
|
||||||
}
|
}
|
||||||
@@ -2503,8 +2751,14 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
}
|
}
|
||||||
r.textContent = _roleText;
|
r.textContent = _roleText;
|
||||||
if (role !== 'user') {
|
if (role !== 'user') {
|
||||||
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
|
const endpointChanged = Boolean(
|
||||||
r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
|
replyModels.requestedEndpointId
|
||||||
|
&& replyModels.actualEndpointId
|
||||||
|
&& replyModels.requestedEndpointId !== replyModels.actualEndpointId
|
||||||
|
);
|
||||||
|
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) {
|
||||||
|
r.title = replyModels.requestedModel + ' -> ' + resolvedModel
|
||||||
|
+ ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')';
|
||||||
}
|
}
|
||||||
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
|
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
|
||||||
r.appendChild(roleTimestamp(metadata?.timestamp));
|
r.appendChild(roleTimestamp(metadata?.timestamp));
|
||||||
@@ -2788,6 +3042,7 @@ const chatRenderer = {
|
|||||||
getSessionCost,
|
getSessionCost,
|
||||||
resetSessionCost,
|
resetSessionCost,
|
||||||
updateSessionCostUI,
|
updateSessionCostUI,
|
||||||
|
recordSessionMetricsCost,
|
||||||
roleTimestamp,
|
roleTimestamp,
|
||||||
stripToolBlocks,
|
stripToolBlocks,
|
||||||
copyMessageText,
|
copyMessageText,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Storage from './storage.js';
|
|||||||
import themeModule from './theme.js';
|
import themeModule from './theme.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import sessionModule from './sessions.js';
|
import sessionModule from './sessions.js';
|
||||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
import documentModule from './document.js?v=20260815approvalsave1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
||||||
@@ -156,7 +156,7 @@ export function handleUIControl(uiData) {
|
|||||||
if (fn) fn();
|
if (fn) fn();
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
} else if (panel === 'email') {
|
} else if (panel === 'email') {
|
||||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(function(mod) {
|
import('./emailLibrary.js?v=20260815approvalsave1').then(function(mod) {
|
||||||
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||||
if (fn) fn();
|
if (fn) fn();
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
@@ -205,7 +205,7 @@ export function handleUIControl(uiData) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('open_email_reply existing draft update failed:', e);
|
console.warn('open_email_reply existing draft update failed:', e);
|
||||||
}
|
}
|
||||||
import('./emailInbox.js?v=20260722emailfastindex1').then(function(mod) {
|
import('./emailInbox.js?v=20260815approvalsave1').then(function(mod) {
|
||||||
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
||||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
||||||
}).catch(function(e) {
|
}).catch(function(e) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** Build a terminal stream error while preserving provider-supplied text. */
|
||||||
|
export function createTerminalStreamError(payload = {}) {
|
||||||
|
const rawError = payload.error;
|
||||||
|
const message = (
|
||||||
|
payload.text
|
||||||
|
|| (typeof rawError === 'string' ? rawError : rawError?.message)
|
||||||
|
|| `Error ${payload.status || 'unknown'}`
|
||||||
|
);
|
||||||
|
const error = new Error(message);
|
||||||
|
error.name = 'TerminalStreamError';
|
||||||
|
error.terminalStreamError = true;
|
||||||
|
error.status = payload.status;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only connection-class stream failures are safe to resubmit automatically. */
|
||||||
|
export function isRecoverableStreamError(error) {
|
||||||
|
if (!error || error.terminalStreamError || error.name === 'TerminalStreamError') return false;
|
||||||
|
if (error.name === 'TypeError') return true;
|
||||||
|
const message = (error.message || '').toLowerCase();
|
||||||
|
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(message)) return false;
|
||||||
|
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(message);
|
||||||
|
}
|
||||||
@@ -3934,7 +3934,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
leadingIcon: 'check',
|
leadingIcon: 'check',
|
||||||
action: 'View Message',
|
action: 'View Message',
|
||||||
onAction: () => {
|
onAction: () => {
|
||||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
|
import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
|
||||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||||
if (open) open({
|
if (open) open({
|
||||||
account_id: data.account_id || activeAccountId || null,
|
account_id: data.account_id || activeAccountId || null,
|
||||||
@@ -9401,9 +9401,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
|
|
||||||
/** Save manual edits */
|
/** Save manual edits */
|
||||||
export async function saveDocument({ silent = false, forceVersion = false } = {}) {
|
export async function saveDocument({ silent = false, forceVersion = false } = {}) {
|
||||||
if (!activeDocId) return;
|
if (!activeDocId) return false;
|
||||||
const textarea = document.getElementById('doc-editor-textarea');
|
const textarea = document.getElementById('doc-editor-textarea');
|
||||||
if (!textarea) return;
|
if (!textarea) return false;
|
||||||
const savingDocId = activeDocId;
|
const savingDocId = activeDocId;
|
||||||
saveCurrentToMap();
|
saveCurrentToMap();
|
||||||
const localDoc = docs.get(savingDocId);
|
const localDoc = docs.get(savingDocId);
|
||||||
@@ -9422,7 +9422,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
});
|
});
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
if (silent && localDoc?.language === 'email') {
|
if (silent && localDoc?.language === 'email') {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
// Streaming/empty email drafts can leave a local tab pointing at a temp
|
// Streaming/empty email drafts can leave a local tab pointing at a temp
|
||||||
// or already-deleted document. Do not keep surfacing autosave errors for
|
// or already-deleted document. Do not keep surfacing autosave errors for
|
||||||
@@ -9434,7 +9434,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
}
|
}
|
||||||
_syncDocIndicator();
|
_syncDocIndicator();
|
||||||
if (!silent && uiModule) uiModule.showError('Document no longer exists');
|
if (!silent && uiModule) uiModule.showError('Document no longer exists');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
|
||||||
const doc = await res.json();
|
const doc = await res.json();
|
||||||
@@ -9447,6 +9447,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
}
|
}
|
||||||
_syncDocIndicator();
|
_syncDocIndicator();
|
||||||
if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved');
|
if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved');
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save document:', e);
|
console.error('Failed to save document:', e);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -9454,6 +9455,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document');
|
uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document');
|
||||||
_lastAutoSaveErrorAt = now;
|
_lastAutoSaveErrorAt = now;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import sessionModule from './sessions.js';
|
import sessionModule from './sessions.js';
|
||||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260722emailfastindex1';
|
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260815approvalsave1';
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
import { applyEdgeDock } from './modalSnap.js';
|
import { applyEdgeDock } from './modalSnap.js';
|
||||||
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
|
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
|
import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
|
||||||
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1';
|
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260815approvalsave1';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
import { topPortalZ } from './toolWindowZOrder.js';
|
import { topPortalZ } from './toolWindowZOrder.js';
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
_tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON,
|
_tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON,
|
||||||
} from './emailLibrary/signatureFold.js';
|
} from './emailLibrary/signatureFold.js';
|
||||||
import { state } from './emailLibrary/state.js';
|
import { state } from './emailLibrary/state.js';
|
||||||
|
import { getSettings } from './appConfig.js';
|
||||||
import { collapseSidebarToRail } from './modalSnap.js';
|
import { collapseSidebarToRail } from './modalSnap.js';
|
||||||
import { emailApiUrl } from './emailShared.js';
|
import { emailApiUrl } from './emailShared.js';
|
||||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
@@ -993,8 +994,7 @@ function _syncEmailReminderBellVisibility(enabled) {
|
|||||||
|
|
||||||
async function _loadEmailReminderBellVisibility() {
|
async function _loadEmailReminderBellVisibility() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
const settings = await getSettings();
|
||||||
const settings = await res.json();
|
|
||||||
_syncEmailReminderBellVisibility(settings.reminder_channel === 'email');
|
_syncEmailReminderBellVisibility(settings.reminder_channel === 'email');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_syncEmailReminderBellVisibility(false);
|
_syncEmailReminderBellVisibility(false);
|
||||||
@@ -6680,7 +6680,7 @@ function _wireAttachmentHandlers(reader, folder) {
|
|||||||
ownerModal.classList.add('hidden');
|
ownerModal.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const docMod = await import('./document.js?v=20260722emailfastindex1');
|
const docMod = await import('./document.js?v=20260815approvalsave1');
|
||||||
const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
|
const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
|
||||||
if (typeof load === 'function') {
|
if (typeof load === 'function') {
|
||||||
await load(json.doc_id);
|
await load(json.doc_id);
|
||||||
|
|||||||
+49
-1
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import uiModule from './ui.js';
|
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 spinnerModule from './spinner.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
@@ -15,6 +15,54 @@ const API_BASE = window.location.origin;
|
|||||||
let _open = false;
|
let _open = false;
|
||||||
let _galleryResizeHandler = null;
|
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
|
// Auto-refresh gallery when new image is generated
|
||||||
window.addEventListener('gallery-refresh', (e) => {
|
window.addEventListener('gallery-refresh', (e) => {
|
||||||
if (e?.detail?.source === 'chat-upload' && _sort !== 'recent') {
|
if (e?.detail?.source === 'chat-upload' && _sort !== 'recent') {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
import { IS_MAC, isAltGrEvent } from './platform.js';
|
import { IS_MAC, isAltGrEvent } from './platform.js';
|
||||||
|
import { getSettings } from './appConfig.js';
|
||||||
|
|
||||||
const _defaultKeybinds = {
|
const _defaultKeybinds = {
|
||||||
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
|
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
|
||||||
@@ -56,8 +57,7 @@ export function initKeyboardShortcuts(modules) {
|
|||||||
window._odysseusKeybinds = { ..._defaultKeybinds };
|
window._odysseusKeybinds = { ..._defaultKeybinds };
|
||||||
|
|
||||||
// Load saved keybinds
|
// Load saved keybinds
|
||||||
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
getSettings()
|
||||||
.then(r => r.json())
|
|
||||||
.then(s => { if (s.keybinds) window._odysseusKeybinds = { ..._defaultKeybinds, ...s.keybinds }; })
|
.then(s => { if (s.keybinds) window._odysseusKeybinds = { ..._defaultKeybinds, ...s.keybinds }; })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* Search settings management — reads active provider from admin settings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let API_BASE = '';
|
import { getSettings, invalidateSettings } from './appConfig.js';
|
||||||
|
|
||||||
let _provider = 'searxng';
|
let _provider = 'searxng';
|
||||||
let _loaded = false;
|
let _loaded = false;
|
||||||
|
|
||||||
export function init(apiBase) {
|
// No API base parameter any more: the settings request lives in appConfig.js and
|
||||||
API_BASE = apiBase;
|
// 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
|
// Fetch provider on init so it's ready when chat needs it
|
||||||
_fetchProvider();
|
_fetchProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _fetchProvider() {
|
async function _fetchProvider() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch((API_BASE || '') + '/api/auth/settings', { credentials: 'same-origin' });
|
const s = await getSettings();
|
||||||
const s = await res.json();
|
|
||||||
_provider = s.search_provider || 'searxng';
|
_provider = s.search_provider || 'searxng';
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
} catch (e) { /* keep default */ }
|
} catch (e) { /* keep default */ }
|
||||||
@@ -39,6 +40,9 @@ export function getProviderLabel() {
|
|||||||
|
|
||||||
/** Re-fetch after admin saves new settings */
|
/** Re-fetch after admin saves new settings */
|
||||||
export function refresh() {
|
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();
|
_fetchProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import Storage from './storage.js';
|
import Storage from './storage.js';
|
||||||
import uiModule, { autoResize, styledPrompt } from './ui.js';
|
import uiModule, { autoResize, styledPrompt } from './ui.js';
|
||||||
import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';
|
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
|
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
|
||||||
import themeModule from './theme.js';
|
import themeModule from './theme.js';
|
||||||
|
|||||||
+130
-290
@@ -3,144 +3,83 @@
|
|||||||
|
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import searchModule from './search.js';
|
import searchModule from './search.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { byId } from './settings/dom.js';
|
||||||
import { clearDockSide } from './modalSnap.js';
|
import {
|
||||||
|
getSettingsRegistryIssues,
|
||||||
|
isAdminManagedSettingsTab,
|
||||||
|
} from './settings/registry.js';
|
||||||
|
import { bindSettingsSearch } from './settings/search.js';
|
||||||
|
import { bindSettingsSidebar } from './settings/sidebar.js';
|
||||||
|
import {
|
||||||
|
activateSettingsPanel,
|
||||||
|
getActiveSettingsTab,
|
||||||
|
bindSettingsNavigation,
|
||||||
|
} from './settings/navigation.js';
|
||||||
|
import {
|
||||||
|
bindSettingsDrag,
|
||||||
|
bindSettingsClose,
|
||||||
|
bindOpenPromptModalLink,
|
||||||
|
showSettingsModal,
|
||||||
|
hideSettingsModal,
|
||||||
|
} from './settings/lifecycle.js';
|
||||||
import { sortModelIds } from './modelSort.js';
|
import { sortModelIds } from './modelSort.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import { isAltGrEvent } from './platform.js';
|
import { isAltGrEvent } from './platform.js';
|
||||||
import { bindMenuDismiss } from './escMenuStack.js';
|
import { bindMenuDismiss } from './escMenuStack.js';
|
||||||
|
import { invalidateSettings } from './appConfig.js';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
let _authPolicy = { password_min_length: 8 };
|
let _authPolicy = { password_min_length: 8 };
|
||||||
|
|
||||||
function el(id) { return document.getElementById(id); }
|
/**
|
||||||
|
* 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 esc(s) { return uiModule.esc(s); }
|
||||||
function safeRasterDataUrl(raw) {
|
function safeRasterDataUrl(raw) {
|
||||||
const value = String(raw || '').trim();
|
const value = String(raw || '').trim();
|
||||||
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tab switching ── */
|
/* ── Settings shell coordination ── */
|
||||||
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
|
function onSettingsPanelActivated(tab) {
|
||||||
|
// Appearance keeps its existing transparent preview behavior.
|
||||||
|
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||||
|
syncAppearanceOpacity(tab === 'appearance');
|
||||||
|
|
||||||
function initTabs() {
|
// AI endpoints are intentionally refreshed only when entering the AI panel.
|
||||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
|
if (tab === 'ai') refreshAiModelEndpoints();
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const tab = btn.dataset.settingsTab;
|
|
||||||
// Lazy-init admin when first clicking an admin tab
|
|
||||||
if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') {
|
|
||||||
window.adminModule.open(tab);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
|
|
||||||
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
|
|
||||||
// Mark when the Appearance tab is open so the modal can go
|
|
||||||
// semi-transparent — lets the user see the rest of the UI react as
|
|
||||||
// they flip toggles instead of having to close + reopen the modal.
|
|
||||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
|
||||||
syncAppearanceOpacity(tab === 'appearance');
|
|
||||||
if (tab === 'ai') refreshAiModelEndpoints();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dragging ── */
|
function openAdminSettingsTab(tab) {
|
||||||
function initDrag() {
|
if (window.adminModule && typeof window.adminModule.open === 'function') {
|
||||||
const header = modalEl.querySelector('.modal-header');
|
window.adminModule.open(tab);
|
||||||
const content = modalEl.querySelector('.settings-modal-content');
|
return true;
|
||||||
if (!header || !content) return;
|
|
||||||
// Skip interactive controls in the header (e.g. the opacity slider) so
|
|
||||||
// grabbing them doesn't start a window-drag.
|
|
||||||
makeWindowDraggable(modalEl, {
|
|
||||||
content,
|
|
||||||
header,
|
|
||||||
skipSelector: 'button, input, select, .theme-opacity-wrap',
|
|
||||||
enableDock: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetWindowPlacement() {
|
|
||||||
const content = modalEl && modalEl.querySelector('.settings-modal-content');
|
|
||||||
if (!content) return;
|
|
||||||
const hadLeft = modalEl.classList.contains('modal-left-docked');
|
|
||||||
const hadRight = modalEl.classList.contains('modal-right-docked');
|
|
||||||
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
|
|
||||||
if (hadLeft) clearDockSide('left', modalEl);
|
|
||||||
if (hadRight) clearDockSide('right', modalEl);
|
|
||||||
if (content._leftDockNavObs) {
|
|
||||||
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
|
|
||||||
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
|
|
||||||
delete content._leftDockNavObs;
|
|
||||||
}
|
}
|
||||||
delete content._preDockSnapshot;
|
return false;
|
||||||
delete content._dockSide;
|
|
||||||
delete content._dockSuspended;
|
|
||||||
delete content.dataset._tilePreSnap;
|
|
||||||
delete content.dataset._tileZone;
|
|
||||||
[
|
|
||||||
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
|
|
||||||
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
|
|
||||||
].forEach(prop => content.style.removeProperty(prop));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Delegated link: close Settings + open the Prompt (characters) modal ── */
|
|
||||||
function initOpenPromptModalLink() {
|
|
||||||
document.addEventListener('click', async (e) => {
|
|
||||||
const link = e.target.closest('[data-open-prompt-modal]');
|
|
||||||
if (!link) return;
|
|
||||||
e.preventDefault();
|
|
||||||
// Close settings first so the prompt modal isn't stacked on top.
|
|
||||||
if (modalEl && !modalEl.classList.contains('hidden')) close();
|
|
||||||
try {
|
|
||||||
const m = await import('./presets.js');
|
|
||||||
const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal);
|
|
||||||
if (typeof fn === 'function') fn();
|
|
||||||
} catch (_) {
|
|
||||||
const modal = document.getElementById('custom-preset-modal');
|
|
||||||
if (modal) modal.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
// Force the Persona tab (data-chartab="character") since the link's
|
|
||||||
// whole purpose is editing personas — not landing on Inject by default.
|
|
||||||
const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]');
|
|
||||||
if (personaTab) personaTab.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Close on backdrop / X ── */
|
|
||||||
function initClose() {
|
|
||||||
modalEl.querySelector('.close-btn').addEventListener('click', close);
|
|
||||||
modalEl.addEventListener('mousedown', e => {
|
|
||||||
if (uiModule.isTouchInsideModal()) return;
|
|
||||||
if (e.target === modalEl) close();
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return;
|
|
||||||
// Bail when a transient popover inside the modal is open — Esc should
|
|
||||||
// dismiss just that, not the whole modal. Same-document listeners fire
|
|
||||||
// in registration order regardless of capture/bubble, so the popover's
|
|
||||||
// own handler can't pre-empt ours; we have to opt out here.
|
|
||||||
const popoverOpen = modalEl.querySelector(
|
|
||||||
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
|
|
||||||
);
|
|
||||||
if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If an integration edit/add form is open inside the modal, close
|
|
||||||
// just that — don't dismiss the whole settings modal. (Pressing
|
|
||||||
// ESC mid-edit and losing the modal was a fast-typing footgun.)
|
|
||||||
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
|
|
||||||
if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
innerForm.style.display = 'none';
|
|
||||||
innerForm.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Appearance-tab opacity slider ──
|
/* ── Appearance-tab opacity slider ──
|
||||||
@@ -363,10 +302,7 @@ function _bindFallbackWidget(opts) {
|
|||||||
var body = {};
|
var body = {};
|
||||||
body[settingKey] = clean;
|
body[settingKey] = clean;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings(body);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
} catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
|
} catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,14 +381,7 @@ async function initDefaultChat() {
|
|||||||
var epSel = el('set-defaultEpSelect');
|
var epSel = el('set-defaultEpSelect');
|
||||||
var modelSel = el('set-defaultModelSelect');
|
var modelSel = el('set-defaultModelSelect');
|
||||||
var msg = el('set-defaultChatMsg');
|
var msg = el('set-defaultChatMsg');
|
||||||
var fbContainer = el('set-defaultFallbacks');
|
|
||||||
var addFbBtn = el('set-defaultAddFallback');
|
|
||||||
var _endpoints = [];
|
var _endpoints = [];
|
||||||
var _fallbacks = []; // Hidden legacy DOM hook; stored values are not loaded or saved.
|
|
||||||
|
|
||||||
function enabledEndpoints() {
|
|
||||||
return _endpoints.filter(function(e) { return e.is_enabled; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill any <select> with the models for a given endpoint id.
|
// Fill any <select> with the models for a given endpoint id.
|
||||||
function fillModels(selectEl, epId, selected) {
|
function fillModels(selectEl, epId, selected) {
|
||||||
@@ -469,64 +398,6 @@ async function initDefaultChat() {
|
|||||||
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
|
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
|
||||||
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
|
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
|
||||||
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
|
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
|
||||||
renderFallbacks();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the fallback chain. Each row is endpoint + model + remove.
|
|
||||||
function renderFallbacks() {
|
|
||||||
fbContainer.innerHTML = '';
|
|
||||||
_fallbacks.forEach(function(fb, idx) {
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'settings-fallback-row';
|
|
||||||
|
|
||||||
var num = document.createElement('span');
|
|
||||||
num.className = 'settings-fallback-num';
|
|
||||||
num.textContent = (idx + 1) + '.';
|
|
||||||
|
|
||||||
var epS = document.createElement('select');
|
|
||||||
epS.className = 'settings-select';
|
|
||||||
enabledEndpoints().forEach(function(ep) {
|
|
||||||
var o = document.createElement('option');
|
|
||||||
o.value = ep.id;
|
|
||||||
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
|
||||||
epS.appendChild(o);
|
|
||||||
});
|
|
||||||
var first = enabledEndpoints()[0];
|
|
||||||
epS.value = fb.endpoint_id || (first ? first.id : '');
|
|
||||||
|
|
||||||
var mS = document.createElement('select');
|
|
||||||
mS.className = 'settings-select';
|
|
||||||
fillModels(mS, epS.value, fb.model);
|
|
||||||
|
|
||||||
// Keep the model in sync with the values actually shown.
|
|
||||||
fb.endpoint_id = epS.value;
|
|
||||||
fb.model = mS.value;
|
|
||||||
|
|
||||||
epS.addEventListener('change', function() {
|
|
||||||
fb.endpoint_id = epS.value;
|
|
||||||
fillModels(mS, epS.value, '');
|
|
||||||
fb.model = mS.value;
|
|
||||||
saveDefault();
|
|
||||||
});
|
|
||||||
mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
|
|
||||||
|
|
||||||
var rm = document.createElement('button');
|
|
||||||
rm.type = 'button';
|
|
||||||
rm.className = 'settings-fallback-remove';
|
|
||||||
rm.title = 'Remove fallback';
|
|
||||||
rm.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
|
||||||
rm.addEventListener('click', function() {
|
|
||||||
_fallbacks.splice(idx, 1);
|
|
||||||
renderFallbacks();
|
|
||||||
saveDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
row.appendChild(num);
|
|
||||||
row.appendChild(epS);
|
|
||||||
row.appendChild(mS);
|
|
||||||
row.appendChild(rm);
|
|
||||||
fbContainer.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -534,7 +405,6 @@ async function initDefaultChat() {
|
|||||||
var settings = await res.json();
|
var settings = await res.json();
|
||||||
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
||||||
refreshModels(settings.default_model || '');
|
refreshModels(settings.default_model || '');
|
||||||
renderFallbacks();
|
|
||||||
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
||||||
|
|
||||||
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
|
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
|
||||||
@@ -542,25 +412,15 @@ async function initDefaultChat() {
|
|||||||
|
|
||||||
async function saveDefault() {
|
async function saveDefault() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
default_endpoint_id: epSel.value,
|
||||||
body: JSON.stringify({
|
default_model: modelSel.value
|
||||||
default_endpoint_id: epSel.value,
|
|
||||||
default_model: modelSel.value
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
setTimeout(function() { msg.textContent = ''; }, 2000);
|
setTimeout(function() { msg.textContent = ''; }, 2000);
|
||||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addFbBtn) addFbBtn.addEventListener('click', function() {
|
|
||||||
var first = enabledEndpoints()[0];
|
|
||||||
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
|
|
||||||
renderFallbacks();
|
|
||||||
saveDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
_registerAiEndpointRefresh(function(endpoints) {
|
_registerAiEndpointRefresh(function(endpoints) {
|
||||||
_endpoints = endpoints;
|
_endpoints = endpoints;
|
||||||
refreshEndpointOptions(epSel.value, modelSel.value);
|
refreshEndpointOptions(epSel.value, modelSel.value);
|
||||||
@@ -609,12 +469,9 @@ async function initUtilityModel() {
|
|||||||
// no toggle, "—" means "unset, use chat").
|
// no toggle, "—" means "unset, use chat").
|
||||||
async function saveUtility() {
|
async function saveUtility() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
utility_endpoint_id: epSel.value || '',
|
||||||
body: JSON.stringify({
|
utility_model: modelSel.value || ''
|
||||||
utility_endpoint_id: epSel.value || '',
|
|
||||||
utility_model: modelSel.value || ''
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
setTimeout(function() { msg.textContent = ''; }, 1500);
|
setTimeout(function() { msg.textContent = ''; }, 1500);
|
||||||
@@ -707,10 +564,7 @@ async function initTeacherModel() {
|
|||||||
spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value;
|
spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value;
|
||||||
}
|
}
|
||||||
var enabled = enabledToggle ? !!enabledToggle.checked : false;
|
var enabled = enabledToggle ? !!enabledToggle.checked : false;
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings({ teacher_enabled: enabled, teacher_model: spec });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ teacher_enabled: enabled, teacher_model: spec })
|
|
||||||
});
|
|
||||||
msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled';
|
msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled';
|
||||||
msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)';
|
msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)';
|
||||||
setTimeout(function() { msg.textContent = ''; }, 2000);
|
setTimeout(function() { msg.textContent = ''; }, 2000);
|
||||||
@@ -785,8 +639,7 @@ async function initImageSettings() {
|
|||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
||||||
body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }) });
|
|
||||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
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);
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||||
@@ -860,8 +713,7 @@ async function initVisionSettings() {
|
|||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
await _postSettings({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value });
|
||||||
body: JSON.stringify({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value }) });
|
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||||
}
|
}
|
||||||
@@ -942,8 +794,7 @@ async function initTtsSettings() {
|
|||||||
|
|
||||||
async function saveTTS() {
|
async function saveTTS() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
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' });
|
||||||
body: JSON.stringify({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' }) });
|
|
||||||
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
|
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
|
||||||
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
|
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
|
||||||
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
||||||
@@ -1104,9 +955,7 @@ async function initSttSettings() {
|
|||||||
async function saveSTT() {
|
async function saveSTT() {
|
||||||
try {
|
try {
|
||||||
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
|
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() }) });
|
|
||||||
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
|
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
|
||||||
// Notify voiceRecorder of effective provider and update send button icon
|
// Notify voiceRecorder of effective provider and update send button icon
|
||||||
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
|
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
|
||||||
@@ -1262,10 +1111,7 @@ async function initSearchSettings() {
|
|||||||
payload[kf] = keyInput.value.trim();
|
payload[kf] = keyInput.value.trim();
|
||||||
_settings[kf] = keyInput.value.trim();
|
_settings[kf] = keyInput.value.trim();
|
||||||
}
|
}
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings(payload);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
setTimeout(refreshStatus, 2000);
|
setTimeout(refreshStatus, 2000);
|
||||||
if (searchModule && searchModule.refresh) searchModule.refresh();
|
if (searchModule && searchModule.refresh) searchModule.refresh();
|
||||||
@@ -1417,11 +1263,7 @@ async function initSearchSettings() {
|
|||||||
async function _saveFallbackChain(chain) {
|
async function _saveFallbackChain(chain) {
|
||||||
_settings.search_fallback_chain = chain;
|
_settings.search_fallback_chain = chain;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', {
|
await _postSettings({ search_fallback_chain: chain });
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ search_fallback_chain: chain }),
|
|
||||||
});
|
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
setTimeout(refreshStatus, 2000);
|
setTimeout(refreshStatus, 2000);
|
||||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||||
@@ -1585,10 +1427,7 @@ async function initResearchSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings(payload);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
setTimeout(showStatus, 2000);
|
setTimeout(showStatus, 2000);
|
||||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||||
@@ -1652,10 +1491,7 @@ async function initResearchSearchSettings() {
|
|||||||
|
|
||||||
async function saveResearchSearch() {
|
async function saveResearchSearch() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings({ research_search_provider: searchSel.value });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ research_search_provider: searchSel.value })
|
|
||||||
});
|
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
setTimeout(function() { msg.textContent = ''; }, 2000);
|
setTimeout(function() { msg.textContent = ''; }, 2000);
|
||||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||||
@@ -1697,10 +1533,7 @@ async function initAgentSettings() {
|
|||||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
await _postSettings(payload);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||||
@@ -2095,11 +1928,7 @@ async function initShortcuts() {
|
|||||||
|
|
||||||
async function saveKeybinds() {
|
async function saveKeybinds() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', {
|
await _postSettings({ keybinds });
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ keybinds }),
|
|
||||||
});
|
|
||||||
// Update global keybinds so they take effect immediately
|
// Update global keybinds so they take effect immediately
|
||||||
window._odysseusKeybinds = keybinds;
|
window._odysseusKeybinds = keybinds;
|
||||||
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
|
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
|
||||||
@@ -2311,10 +2140,39 @@ function initAccount() {
|
|||||||
|
|
||||||
function initAll() {
|
function initAll() {
|
||||||
modalEl = el('settings-modal');
|
modalEl = el('settings-modal');
|
||||||
initTabs();
|
|
||||||
initDrag();
|
bindSettingsNavigation(modalEl, {
|
||||||
initClose();
|
openAdminTab: openAdminSettingsTab,
|
||||||
initOpenPromptModalLink();
|
onPanelActivated: onSettingsPanelActivated,
|
||||||
|
});
|
||||||
|
|
||||||
|
bindSettingsSearch(modalEl, {
|
||||||
|
isAdmin: () => !!window._isAdmin,
|
||||||
|
openPanel(tab) {
|
||||||
|
const button = modalEl.querySelector(`[data-settings-tab="${tab}"]`);
|
||||||
|
if (button) button.click();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
bindSettingsSidebar(modalEl);
|
||||||
|
|
||||||
|
const registryIssues = getSettingsRegistryIssues(modalEl);
|
||||||
|
if (registryIssues.length) {
|
||||||
|
console.warn('Settings registry/DOM mismatch:', registryIssues);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindSettingsDrag(modalEl);
|
||||||
|
|
||||||
|
bindSettingsClose(modalEl, {
|
||||||
|
closeSettings: close,
|
||||||
|
isTouchInsideModal: () => uiModule.isTouchInsideModal(),
|
||||||
|
});
|
||||||
|
|
||||||
|
bindOpenPromptModalLink({
|
||||||
|
getModal: () => modalEl,
|
||||||
|
closeSettings: close,
|
||||||
|
});
|
||||||
|
|
||||||
initOpacityToggle();
|
initOpacityToggle();
|
||||||
initialized = true;
|
initialized = true;
|
||||||
initDefaultChat();
|
initDefaultChat();
|
||||||
@@ -2363,11 +2221,7 @@ async function initReminderSettings() {
|
|||||||
pubDebounce = setTimeout(async () => {
|
pubDebounce = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const val = pubUrlIn.value.trim().replace(/\/+$/, '');
|
const val = pubUrlIn.value.trim().replace(/\/+$/, '');
|
||||||
await fetch('/api/auth/settings', {
|
await _postSettings({ app_public_url: val });
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ app_public_url: val }),
|
|
||||||
});
|
|
||||||
if (pubUrlMsg) {
|
if (pubUrlMsg) {
|
||||||
pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)';
|
pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)';
|
||||||
pubUrlMsg.style.color = 'var(--green,#50fa7b)';
|
pubUrlMsg.style.color = 'var(--green,#50fa7b)';
|
||||||
@@ -2665,12 +2519,7 @@ async function initReminderSettings() {
|
|||||||
|
|
||||||
async function save(patch) {
|
async function save(patch) {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/settings', {
|
await _postSettings(patch);
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(patch),
|
|
||||||
});
|
|
||||||
} catch (e) { console.warn('Failed to save reminder settings', e); }
|
} catch (e) { console.warn('Failed to save reminder settings', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2818,7 +2667,7 @@ async function initEmailAccountsSettings() {
|
|||||||
|
|
||||||
el('set-email-open-library-settings')?.addEventListener('click', async () => {
|
el('set-email-open-library-settings')?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const mod = await import('./emailLibrary.js?v=20260722emailfastindex1');
|
const mod = await import('./emailLibrary.js?v=20260815approvalsave1');
|
||||||
if (typeof mod.openEmailLibrarySettings === 'function') {
|
if (typeof mod.openEmailLibrarySettings === 'function') {
|
||||||
await mod.openEmailLibrarySettings();
|
await mod.openEmailLibrarySettings();
|
||||||
}
|
}
|
||||||
@@ -5734,44 +5583,35 @@ function syncAdminVisibility() {
|
|||||||
═══════════════════════════════════════════ */
|
═══════════════════════════════════════════ */
|
||||||
export function open(tab) {
|
export function open(tab) {
|
||||||
if (!initialized) initAll();
|
if (!initialized) initAll();
|
||||||
|
|
||||||
syncAppearanceCheckboxes();
|
syncAppearanceCheckboxes();
|
||||||
if (modalEl.classList.contains('hidden')) {
|
showSettingsModal(modalEl);
|
||||||
resetWindowPlacement();
|
|
||||||
}
|
|
||||||
modalEl.classList.remove('hidden');
|
|
||||||
syncAdminVisibility();
|
syncAdminVisibility();
|
||||||
const content = modalEl.querySelector('.settings-modal-content');
|
|
||||||
if (tab) {
|
if (tab) {
|
||||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
|
activateSettingsPanel(modalEl, tab);
|
||||||
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
|
|
||||||
}
|
}
|
||||||
// Auto-init admin data if showing an admin tab
|
|
||||||
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
|
// Preserve existing panel-specific side effects when Settings is opened
|
||||||
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
|
// directly to a tab as well as when the user navigates there.
|
||||||
syncAppearanceOpacity(activeTab === 'appearance');
|
const activeTab = tab || getActiveSettingsTab(modalEl);
|
||||||
if (activeTab === 'ai') refreshAiModelEndpoints();
|
onSettingsPanelActivated(activeTab);
|
||||||
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
|
||||||
|
// Auto-init admin data if showing an admin tab.
|
||||||
|
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||||
window.adminModule._initData();
|
window.adminModule._initData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function close() {
|
export function close() {
|
||||||
if (!modalEl) return;
|
if (!modalEl) return;
|
||||||
// Always clear the appearance-tab body class so the rest of the app
|
|
||||||
// doesn't keep its dimmed state if the modal got closed mid-tab.
|
// Always clear the Appearance state so the rest of the app does not remain
|
||||||
|
// dimmed if Settings is closed while that panel is active.
|
||||||
document.body.classList.remove('settings-appearance-open');
|
document.body.classList.remove('settings-appearance-open');
|
||||||
syncAppearanceOpacity(false); // clear any opacity-slider fade
|
syncAppearanceOpacity(false);
|
||||||
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
|
|
||||||
if (content && !content.classList.contains('modal-closing')) {
|
hideSettingsModal(modalEl);
|
||||||
content.classList.add('modal-closing');
|
|
||||||
content.addEventListener('animationend', () => {
|
|
||||||
modalEl.classList.add('hidden');
|
|
||||||
content.classList.remove('modal-closing');
|
|
||||||
}, { once: true });
|
|
||||||
setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
|
|
||||||
} else {
|
|
||||||
modalEl.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
|
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Shared DOM helpers for the Settings modules.
|
||||||
|
// Keep this module intentionally small so panel modules do not grow their own
|
||||||
|
// element-lookup conventions as Settings is split out of settings.js.
|
||||||
|
|
||||||
|
export function byId(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// Settings modal lifecycle primitives.
|
||||||
|
//
|
||||||
|
// Panel-specific behavior belongs elsewhere. This module owns only the window
|
||||||
|
// shell: dragging/docking reset, close semantics, visibility animation, and the
|
||||||
|
// delegated link that leaves Settings for the Persona editor.
|
||||||
|
|
||||||
|
import { makeWindowDraggable } from '../windowDrag.js';
|
||||||
|
import { clearDockSide } from '../modalSnap.js';
|
||||||
|
|
||||||
|
const _dragBound = new WeakSet();
|
||||||
|
const _closeBound = new WeakSet();
|
||||||
|
let _promptLinkBound = false;
|
||||||
|
|
||||||
|
export function bindSettingsDrag(modalEl) {
|
||||||
|
if (!modalEl || _dragBound.has(modalEl)) return;
|
||||||
|
|
||||||
|
const header = modalEl.querySelector('.modal-header');
|
||||||
|
const content = modalEl.querySelector('.settings-modal-content');
|
||||||
|
if (!header || !content) return;
|
||||||
|
|
||||||
|
_dragBound.add(modalEl);
|
||||||
|
makeWindowDraggable(modalEl, {
|
||||||
|
content,
|
||||||
|
header,
|
||||||
|
skipSelector: 'button, input, select, .theme-opacity-wrap',
|
||||||
|
enableDock: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSettingsWindowPlacement(modalEl) {
|
||||||
|
const content = modalEl?.querySelector('.settings-modal-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const hadLeft = modalEl.classList.contains('modal-left-docked');
|
||||||
|
const hadRight = modalEl.classList.contains('modal-right-docked');
|
||||||
|
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
|
||||||
|
if (hadLeft) clearDockSide('left', modalEl);
|
||||||
|
if (hadRight) clearDockSide('right', modalEl);
|
||||||
|
|
||||||
|
if (content._leftDockNavObs) {
|
||||||
|
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
|
||||||
|
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
|
||||||
|
delete content._leftDockNavObs;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete content._preDockSnapshot;
|
||||||
|
delete content._dockSide;
|
||||||
|
delete content._dockSuspended;
|
||||||
|
delete content.dataset._tilePreSnap;
|
||||||
|
delete content.dataset._tileZone;
|
||||||
|
|
||||||
|
[
|
||||||
|
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
|
||||||
|
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
|
||||||
|
].forEach(property => content.style.removeProperty(property));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindOpenPromptModalLink({ getModal, closeSettings } = {}) {
|
||||||
|
if (_promptLinkBound) return;
|
||||||
|
_promptLinkBound = true;
|
||||||
|
|
||||||
|
document.addEventListener('click', async event => {
|
||||||
|
const link = event.target?.closest?.('[data-open-prompt-modal]');
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const settingsModal = typeof getModal === 'function' ? getModal() : null;
|
||||||
|
if (
|
||||||
|
settingsModal
|
||||||
|
&& !settingsModal.classList.contains('hidden')
|
||||||
|
&& typeof closeSettings === 'function'
|
||||||
|
) {
|
||||||
|
closeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const module = await import('../presets.js');
|
||||||
|
const openPrompt = module.openCustomPresetModal
|
||||||
|
|| (module.default && module.default.openCustomPresetModal);
|
||||||
|
if (typeof openPrompt === 'function') openPrompt();
|
||||||
|
} catch (_) {
|
||||||
|
const modal = document.getElementById('custom-preset-modal');
|
||||||
|
if (modal) modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const personaTab = document.querySelector(
|
||||||
|
'#custom-preset-modal .preset-tab[data-chartab="character"]'
|
||||||
|
);
|
||||||
|
if (personaTab) personaTab.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindSettingsClose(modalEl, options = {}) {
|
||||||
|
if (!modalEl || _closeBound.has(modalEl)) return;
|
||||||
|
_closeBound.add(modalEl);
|
||||||
|
|
||||||
|
const closeSettings = options.closeSettings;
|
||||||
|
const isTouchInsideModal = options.isTouchInsideModal;
|
||||||
|
|
||||||
|
const closeButton = modalEl.querySelector('.close-btn');
|
||||||
|
closeButton?.addEventListener('click', () => {
|
||||||
|
if (typeof closeSettings === 'function') closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
modalEl.addEventListener('mousedown', event => {
|
||||||
|
if (typeof isTouchInsideModal === 'function' && isTouchInsideModal()) return;
|
||||||
|
if (event.target === modalEl && typeof closeSettings === 'function') {
|
||||||
|
closeSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
if (event.key !== 'Escape' || modalEl.classList.contains('hidden')) return;
|
||||||
|
|
||||||
|
// Esc should dismiss transient popovers before the Settings window.
|
||||||
|
const popoverOpen = modalEl.querySelector(
|
||||||
|
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
popoverOpen
|
||||||
|
&& popoverOpen.style.display !== 'none'
|
||||||
|
&& !popoverOpen.classList.contains('hidden')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration/account editors are nested flows. Close the editor first so
|
||||||
|
// an accidental Esc does not discard the entire Settings context.
|
||||||
|
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
|
||||||
|
if (
|
||||||
|
innerForm
|
||||||
|
&& innerForm.style.display !== 'none'
|
||||||
|
&& innerForm.children.length > 0
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
innerForm.style.display = 'none';
|
||||||
|
innerForm.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (typeof closeSettings === 'function') closeSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSettingsModal(modalEl) {
|
||||||
|
if (!modalEl) return;
|
||||||
|
if (modalEl.classList.contains('hidden')) {
|
||||||
|
resetSettingsWindowPlacement(modalEl);
|
||||||
|
}
|
||||||
|
modalEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideSettingsModal(modalEl) {
|
||||||
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
|
||||||
|
if (content && !content.classList.contains('modal-closing')) {
|
||||||
|
content.classList.add('modal-closing');
|
||||||
|
content.addEventListener('animationend', () => {
|
||||||
|
modalEl.classList.add('hidden');
|
||||||
|
content.classList.remove('modal-closing');
|
||||||
|
}, { once: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!modalEl.classList.contains('hidden')) {
|
||||||
|
modalEl.classList.add('hidden');
|
||||||
|
content.classList.remove('modal-closing');
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalEl.classList.add('hidden');
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Settings navigation primitives.
|
||||||
|
//
|
||||||
|
// This module owns panel activation and sidebar click routing only. Individual
|
||||||
|
// panels continue to own their data loading and side effects.
|
||||||
|
|
||||||
|
import { DEFAULT_SETTINGS_PANEL_ID, isAdminManagedSettingsTab } from './registry.js';
|
||||||
|
|
||||||
|
const _boundModals = new WeakSet();
|
||||||
|
|
||||||
|
export function activateSettingsPanel(modalEl, tab) {
|
||||||
|
if (!modalEl || !tab) return null;
|
||||||
|
|
||||||
|
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
|
||||||
|
button.classList.toggle('active', button.dataset.settingsTab === tab);
|
||||||
|
});
|
||||||
|
modalEl.querySelectorAll('[data-settings-panel]').forEach(panel => {
|
||||||
|
panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tab);
|
||||||
|
});
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveSettingsTab(modalEl, fallback = DEFAULT_SETTINGS_PANEL_ID) {
|
||||||
|
if (!modalEl) return fallback;
|
||||||
|
const active = modalEl.querySelector('[data-settings-tab].active');
|
||||||
|
return active?.dataset?.settingsTab || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindSettingsNavigation(modalEl, options = {}) {
|
||||||
|
if (!modalEl || _boundModals.has(modalEl)) return;
|
||||||
|
_boundModals.add(modalEl);
|
||||||
|
|
||||||
|
const openAdminTab = options.openAdminTab;
|
||||||
|
const onPanelActivated = options.onPanelActivated;
|
||||||
|
|
||||||
|
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const tab = button.dataset.settingsTab;
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
// Preserve the existing lazy-admin path: when the admin module accepts
|
||||||
|
// the tab, it owns activation/rendering and the Settings shell does not
|
||||||
|
// perform a second local switch.
|
||||||
|
if (
|
||||||
|
isAdminManagedSettingsTab(tab)
|
||||||
|
&& typeof openAdminTab === 'function'
|
||||||
|
&& openAdminTab(tab, button) === true
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activateSettingsPanel(modalEl, tab);
|
||||||
|
if (typeof onPanelActivated === 'function') {
|
||||||
|
onPanelActivated(tab, button);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Canonical metadata for the existing Settings information architecture.
|
||||||
|
//
|
||||||
|
// This module describes Settings; it does not render the sidebar, load panel
|
||||||
|
// data, or own panel behavior. Keeping those concerns separate lets the
|
||||||
|
// current markup remain stable while navigation/search code shares one source
|
||||||
|
// of truth for panel identity and ownership.
|
||||||
|
|
||||||
|
function defineGroup(definition) {
|
||||||
|
return Object.freeze({ ...definition });
|
||||||
|
}
|
||||||
|
|
||||||
|
function definePanel(definition) {
|
||||||
|
return Object.freeze({
|
||||||
|
controller: 'settings',
|
||||||
|
adminOnly: false,
|
||||||
|
...definition,
|
||||||
|
keywords: Object.freeze([...(definition.keywords || [])]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTINGS_GROUPS = Object.freeze([
|
||||||
|
defineGroup({
|
||||||
|
id: 'models',
|
||||||
|
label: 'Models & AI',
|
||||||
|
}),
|
||||||
|
defineGroup({
|
||||||
|
id: 'communications',
|
||||||
|
label: 'Communications',
|
||||||
|
}),
|
||||||
|
defineGroup({
|
||||||
|
id: 'experience',
|
||||||
|
label: 'Experience',
|
||||||
|
}),
|
||||||
|
defineGroup({
|
||||||
|
id: 'account',
|
||||||
|
label: 'Account',
|
||||||
|
}),
|
||||||
|
defineGroup({
|
||||||
|
id: 'administration',
|
||||||
|
label: 'Administration',
|
||||||
|
adminOnly: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Order intentionally mirrors the existing Settings sidebar.
|
||||||
|
export const SETTINGS_PANELS = Object.freeze([
|
||||||
|
definePanel({
|
||||||
|
id: 'services',
|
||||||
|
label: 'Add Models',
|
||||||
|
group: 'models',
|
||||||
|
controller: 'admin',
|
||||||
|
keywords: ['models', 'provider', 'endpoint'],
|
||||||
|
}),
|
||||||
|
definePanel({
|
||||||
|
id: 'added-models',
|
||||||
|
label: 'Added Models',
|
||||||
|
group: 'models',
|
||||||
|
controller: 'admin',
|
||||||
|
keywords: ['models', 'configured', 'provider', 'endpoint'],
|
||||||
|
}),
|
||||||
|
definePanel({
|
||||||
|
id: 'ai',
|
||||||
|
label: 'AI Defaults',
|
||||||
|
group: 'models',
|
||||||
|
keywords: ['ai', 'defaults', 'model', 'vision', 'image', 'tts', 'stt'],
|
||||||
|
}),
|
||||||
|
definePanel({
|
||||||
|
id: 'search',
|
||||||
|
label: 'Search',
|
||||||
|
group: 'models',
|
||||||
|
keywords: ['search', 'research', 'provider'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
definePanel({
|
||||||
|
id: 'integrations',
|
||||||
|
label: 'Integrations',
|
||||||
|
group: 'communications',
|
||||||
|
controller: 'admin',
|
||||||
|
keywords: ['integrations', 'connections', 'services'],
|
||||||
|
}),
|
||||||
|
definePanel({
|
||||||
|
id: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
group: 'communications',
|
||||||
|
keywords: ['email', 'imap', 'smtp', 'oauth'],
|
||||||
|
}),
|
||||||
|
definePanel({
|
||||||
|
id: 'reminders',
|
||||||
|
label: 'Reminders',
|
||||||
|
group: 'communications',
|
||||||
|
keywords: ['reminders', 'notifications', 'alerts'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
definePanel({
|
||||||
|
id: 'appearance',
|
||||||
|
label: 'Appearance',
|
||||||
|
group: 'experience',
|
||||||
|
keywords: ['appearance', 'theme', 'font', 'density', 'peek'],
|
||||||
|
}),
|
||||||
|
definePanel({
|
||||||
|
id: 'shortcuts',
|
||||||
|
label: 'Shortcuts',
|
||||||
|
group: 'experience',
|
||||||
|
keywords: ['shortcuts', 'keyboard', 'hotkeys'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
definePanel({
|
||||||
|
id: 'account',
|
||||||
|
label: 'Account',
|
||||||
|
group: 'account',
|
||||||
|
keywords: ['account', 'password', 'logout'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
definePanel({
|
||||||
|
id: 'tools',
|
||||||
|
label: 'Agent Tools',
|
||||||
|
group: 'administration',
|
||||||
|
controller: 'admin',
|
||||||
|
adminOnly: true,
|
||||||
|
keywords: ['agent', 'tools'],
|
||||||
|
}),
|
||||||
|
definePanel({
|
||||||
|
id: 'users',
|
||||||
|
label: 'Users',
|
||||||
|
group: 'administration',
|
||||||
|
controller: 'admin',
|
||||||
|
adminOnly: true,
|
||||||
|
keywords: ['users', 'accounts', 'admin'],
|
||||||
|
}),
|
||||||
|
definePanel({
|
||||||
|
id: 'system',
|
||||||
|
label: 'System',
|
||||||
|
group: 'administration',
|
||||||
|
controller: 'admin',
|
||||||
|
adminOnly: true,
|
||||||
|
keywords: ['system', 'admin', 'server'],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS_PANEL_ID = 'services';
|
||||||
|
|
||||||
|
const _panelsById = new Map(
|
||||||
|
SETTINGS_PANELS.map(panel => [panel.id, panel]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getSettingsPanel(id) {
|
||||||
|
return _panelsById.get(String(id || '')) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettingsPanelsForGroup(groupId) {
|
||||||
|
return SETTINGS_PANELS.filter(panel => panel.group === groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminManagedSettingsTab(id) {
|
||||||
|
return getSettingsPanel(id)?.controller === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminOnlySettingsTab(id) {
|
||||||
|
return getSettingsPanel(id)?.adminOnly === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettingsPanelSearchText(panelOrId) {
|
||||||
|
const panel = typeof panelOrId === 'string'
|
||||||
|
? getSettingsPanel(panelOrId)
|
||||||
|
: panelOrId;
|
||||||
|
|
||||||
|
if (!panel) return '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
panel.label,
|
||||||
|
...(panel.keywords || []),
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettingsSearch(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchSettingsPanels(query, options = {}) {
|
||||||
|
const normalized = normalizeSettingsSearch(query);
|
||||||
|
if (!normalized) return [];
|
||||||
|
|
||||||
|
const terms = normalized.split(' ');
|
||||||
|
const isAdmin = options.isAdmin === true;
|
||||||
|
|
||||||
|
return SETTINGS_PANELS.filter(panel => {
|
||||||
|
if (panel.adminOnly && !isAdmin) return false;
|
||||||
|
|
||||||
|
const haystack = getSettingsPanelSearchText(panel);
|
||||||
|
return terms.every(term => haystack.includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettingsRegistryIssues(modalEl) {
|
||||||
|
if (!modalEl) return ['Settings modal is unavailable'];
|
||||||
|
|
||||||
|
const tabIds = Array.from(
|
||||||
|
modalEl.querySelectorAll('[data-settings-tab]'),
|
||||||
|
element => element.dataset.settingsTab,
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
const panelIds = Array.from(
|
||||||
|
modalEl.querySelectorAll('[data-settings-panel]'),
|
||||||
|
element => element.dataset.settingsPanel,
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
const registryIds = SETTINGS_PANELS.map(panel => panel.id);
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
const duplicates = ids => ids.filter(
|
||||||
|
(id, index) => ids.indexOf(id) !== index,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const id of new Set(duplicates(tabIds))) {
|
||||||
|
issues.push(`Duplicate Settings tab: ${id}`);
|
||||||
|
}
|
||||||
|
for (const id of new Set(duplicates(panelIds))) {
|
||||||
|
issues.push(`Duplicate Settings panel: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of registryIds) {
|
||||||
|
if (!tabIds.includes(id)) issues.push(`Registry tab missing from DOM: ${id}`);
|
||||||
|
if (!panelIds.includes(id)) issues.push(`Registry panel missing from DOM: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of tabIds) {
|
||||||
|
if (!registryIds.includes(id)) issues.push(`DOM tab missing from registry: ${id}`);
|
||||||
|
}
|
||||||
|
for (const id of panelIds) {
|
||||||
|
if (!registryIds.includes(id)) issues.push(`DOM panel missing from registry: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
SETTINGS_GROUPS,
|
||||||
|
getSettingsPanel,
|
||||||
|
searchSettingsPanels,
|
||||||
|
} from './registry.js';
|
||||||
|
|
||||||
|
const _boundModals = new WeakSet();
|
||||||
|
|
||||||
|
function groupLabelFor(panel) {
|
||||||
|
const group = SETTINGS_GROUPS.find(candidate => candidate.id === panel.group);
|
||||||
|
return group?.label || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults(resultsEl) {
|
||||||
|
if (!resultsEl) return;
|
||||||
|
resultsEl.replaceChildren();
|
||||||
|
resultsEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultButtons(resultsEl) {
|
||||||
|
if (!resultsEl) return [];
|
||||||
|
return Array.from(resultsEl.querySelectorAll('[data-settings-search-result]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveResult(resultsEl, index) {
|
||||||
|
const buttons = getResultButtons(resultsEl);
|
||||||
|
if (!buttons.length) return -1;
|
||||||
|
|
||||||
|
let next = index;
|
||||||
|
if (next < 0) next = buttons.length - 1;
|
||||||
|
if (next >= buttons.length) next = 0;
|
||||||
|
|
||||||
|
buttons.forEach((button, buttonIndex) => {
|
||||||
|
const active = buttonIndex === next;
|
||||||
|
button.classList.toggle('active', active);
|
||||||
|
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof buttons[next].scrollIntoView === 'function') {
|
||||||
|
buttons[next].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindSettingsSearch(modalEl, options = {}) {
|
||||||
|
if (!modalEl || _boundModals.has(modalEl)) return;
|
||||||
|
|
||||||
|
const input = modalEl.querySelector('#settings-nav-search');
|
||||||
|
const resultsEl = modalEl.querySelector('#settings-nav-search-results');
|
||||||
|
|
||||||
|
if (!input || !resultsEl) return;
|
||||||
|
_boundModals.add(modalEl);
|
||||||
|
|
||||||
|
const isAdmin = typeof options.isAdmin === 'function'
|
||||||
|
? options.isAdmin
|
||||||
|
: () => false;
|
||||||
|
|
||||||
|
const openPanel = typeof options.openPanel === 'function'
|
||||||
|
? options.openPanel
|
||||||
|
: () => {};
|
||||||
|
|
||||||
|
let activeIndex = -1;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
input.value = '';
|
||||||
|
activeIndex = -1;
|
||||||
|
clearResults(resultsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateResult(button) {
|
||||||
|
const panelId = button?.dataset?.settingsSearchResult;
|
||||||
|
if (!panelId || !getSettingsPanel(panelId)) return;
|
||||||
|
|
||||||
|
openPanel(panelId);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const query = input.value.trim();
|
||||||
|
activeIndex = -1;
|
||||||
|
resultsEl.replaceChildren();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
resultsEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = searchSettingsPanels(query, {
|
||||||
|
isAdmin: isAdmin(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'settings-search-empty';
|
||||||
|
empty.textContent = 'No settings found';
|
||||||
|
resultsEl.appendChild(empty);
|
||||||
|
resultsEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const panel of matches) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'settings-search-result';
|
||||||
|
button.setAttribute('data-settings-search-result', panel.id);
|
||||||
|
button.setAttribute('role', 'option');
|
||||||
|
button.setAttribute('aria-selected', 'false');
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'settings-search-result-label';
|
||||||
|
label.textContent = panel.label;
|
||||||
|
|
||||||
|
const group = document.createElement('span');
|
||||||
|
group.className = 'settings-search-result-group';
|
||||||
|
group.textContent = groupLabelFor(panel);
|
||||||
|
|
||||||
|
button.append(label, group);
|
||||||
|
button.addEventListener('click', () => activateResult(button));
|
||||||
|
resultsEl.appendChild(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', render);
|
||||||
|
|
||||||
|
input.addEventListener('focus', () => {
|
||||||
|
if (input.value.trim()) render();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', event => {
|
||||||
|
const buttons = getResultButtons(resultsEl);
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (!input.value && resultsEl.classList.contains('hidden')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!buttons.length) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = setActiveResult(resultsEl, activeIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = setActiveResult(resultsEl, activeIndex - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
const target = buttons[activeIndex >= 0 ? activeIndex : 0];
|
||||||
|
if (!target) return;
|
||||||
|
event.preventDefault();
|
||||||
|
activateResult(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modalEl.addEventListener('mousedown', event => {
|
||||||
|
if (event.target === input || resultsEl.contains(event.target)) return;
|
||||||
|
clearResults(resultsEl);
|
||||||
|
activeIndex = -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { reset, render };
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
const STORAGE_WIDTH = 'odysseus-settings-sidebar-width';
|
||||||
|
const STORAGE_COLLAPSED = 'odysseus-settings-sidebar-collapsed';
|
||||||
|
|
||||||
|
export const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 220;
|
||||||
|
export const SETTINGS_SIDEBAR_MIN_WIDTH = 150;
|
||||||
|
export const SETTINGS_SIDEBAR_MAX_WIDTH = 340;
|
||||||
|
export const SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD = 110;
|
||||||
|
|
||||||
|
const _bound = new WeakSet();
|
||||||
|
|
||||||
|
function clampWidth(value) {
|
||||||
|
const width = Number(value);
|
||||||
|
if (!Number.isFinite(width)) return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
||||||
|
return Math.max(
|
||||||
|
SETTINGS_SIDEBAR_MIN_WIDTH,
|
||||||
|
Math.min(SETTINGS_SIDEBAR_MAX_WIDTH, width),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredWidth() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_WIDTH);
|
||||||
|
if (stored == null || String(stored).trim() === '') {
|
||||||
|
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
return clampWidth(stored);
|
||||||
|
} catch {
|
||||||
|
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredCollapsed() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_COLLAPSED) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeWidth(width) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_WIDTH, String(Math.round(width)));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeCollapsed(collapsed) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_COLLAPSED, collapsed ? '1' : '0');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncResizeHandleAria(modalEl, width = null) {
|
||||||
|
const handle = modalEl?.querySelector('#settings-sidebar-resize-handle');
|
||||||
|
if (!handle) return;
|
||||||
|
|
||||||
|
const sidebar = modalEl.querySelector('.settings-sidebar');
|
||||||
|
const collapsed = sidebar?.classList.contains('settings-sidebar-collapsed');
|
||||||
|
|
||||||
|
const current = collapsed
|
||||||
|
? SETTINGS_SIDEBAR_MIN_WIDTH
|
||||||
|
: clampWidth(
|
||||||
|
width ?? sidebar?.getBoundingClientRect?.().width
|
||||||
|
?? SETTINGS_SIDEBAR_DEFAULT_WIDTH
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.setAttribute('aria-valuemin', String(SETTINGS_SIDEBAR_MIN_WIDTH));
|
||||||
|
handle.setAttribute('aria-valuemax', String(SETTINGS_SIDEBAR_MAX_WIDTH));
|
||||||
|
handle.setAttribute('aria-valuenow', String(Math.round(current)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDesktopSidebarMode(modalEl) {
|
||||||
|
const content = modalEl?.querySelector('.settings-modal-content');
|
||||||
|
if (!content) return false;
|
||||||
|
|
||||||
|
// Mirrors the existing container breakpoint where the sidebar becomes a
|
||||||
|
// horizontal rail. Resizing/collapse applies only to the vertical desktop
|
||||||
|
// navigation layout.
|
||||||
|
return content.getBoundingClientRect().width > 620;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSettingsSidebarCollapsed(modalEl, collapsed, options = {}) {
|
||||||
|
const sidebar = modalEl?.querySelector('.settings-sidebar');
|
||||||
|
if (!sidebar) return false;
|
||||||
|
|
||||||
|
const next = collapsed === true;
|
||||||
|
sidebar.classList.toggle('settings-sidebar-collapsed', next);
|
||||||
|
|
||||||
|
const toggle = sidebar.querySelector('#settings-sidebar-toggle');
|
||||||
|
if (toggle) {
|
||||||
|
toggle.setAttribute('aria-expanded', next ? 'false' : 'true');
|
||||||
|
toggle.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
next ? 'Expand settings navigation' : 'Collapse settings navigation',
|
||||||
|
);
|
||||||
|
toggle.title = next
|
||||||
|
? 'Expand settings navigation'
|
||||||
|
: 'Collapse settings navigation';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
const width = clampWidth(options.width ?? readStoredWidth());
|
||||||
|
sidebar.style.setProperty('--settings-sidebar-width', `${width}px`);
|
||||||
|
syncResizeHandleAria(modalEl, width);
|
||||||
|
} else {
|
||||||
|
syncResizeHandleAria(modalEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.persist !== false) storeCollapsed(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSettingsSidebarWidth(modalEl, width, options = {}) {
|
||||||
|
const sidebar = modalEl?.querySelector('.settings-sidebar');
|
||||||
|
if (!sidebar) return null;
|
||||||
|
|
||||||
|
const next = clampWidth(width);
|
||||||
|
sidebar.style.setProperty('--settings-sidebar-width', `${next}px`);
|
||||||
|
syncResizeHandleAria(modalEl, next);
|
||||||
|
|
||||||
|
if (options.persist !== false) storeWidth(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindSettingsSidebar(modalEl) {
|
||||||
|
if (!modalEl || _bound.has(modalEl)) return;
|
||||||
|
_bound.add(modalEl);
|
||||||
|
|
||||||
|
const sidebar = modalEl.querySelector('.settings-sidebar');
|
||||||
|
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
|
||||||
|
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
|
||||||
|
|
||||||
|
if (!sidebar || !handle || !toggle) return;
|
||||||
|
|
||||||
|
setSettingsSidebarWidth(modalEl, readStoredWidth(), { persist: false });
|
||||||
|
setSettingsSidebarCollapsed(
|
||||||
|
modalEl,
|
||||||
|
readStoredCollapsed(),
|
||||||
|
{ persist: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
toggle.addEventListener('click', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (!isDesktopSidebarMode(modalEl)) return;
|
||||||
|
|
||||||
|
const collapsed = sidebar.classList.contains('settings-sidebar-collapsed');
|
||||||
|
setSettingsSidebarCollapsed(modalEl, !collapsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
let startX = 0;
|
||||||
|
let startWidth = 0;
|
||||||
|
|
||||||
|
function stopResize() {
|
||||||
|
if (!sidebar.classList.contains('settings-sidebar-resizing')) return;
|
||||||
|
|
||||||
|
sidebar.classList.remove('settings-sidebar-resizing');
|
||||||
|
document.body.classList.remove('settings-sidebar-resize-active');
|
||||||
|
|
||||||
|
window.removeEventListener('pointermove', onPointerMove);
|
||||||
|
window.removeEventListener('pointerup', stopResize);
|
||||||
|
|
||||||
|
const width = sidebar.getBoundingClientRect().width;
|
||||||
|
if (width < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
|
||||||
|
setSettingsSidebarCollapsed(modalEl, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
|
||||||
|
setSettingsSidebarWidth(modalEl, width);
|
||||||
|
storeCollapsed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event) {
|
||||||
|
const rawWidth = startWidth + (event.clientX - startX);
|
||||||
|
|
||||||
|
if (rawWidth < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
|
||||||
|
sidebar.style.setProperty(
|
||||||
|
'--settings-sidebar-width',
|
||||||
|
`${Math.max(34, rawWidth)}px`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
|
||||||
|
setSettingsSidebarWidth(modalEl, rawWidth, { persist: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.addEventListener('pointerdown', event => {
|
||||||
|
if (!isDesktopSidebarMode(modalEl)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
startX = event.clientX;
|
||||||
|
startWidth = sidebar.getBoundingClientRect().width;
|
||||||
|
|
||||||
|
sidebar.classList.remove('settings-sidebar-collapsed');
|
||||||
|
sidebar.classList.add('settings-sidebar-resizing');
|
||||||
|
document.body.classList.add('settings-sidebar-resize-active');
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onPointerMove);
|
||||||
|
window.addEventListener('pointerup', stopResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('keydown', event => {
|
||||||
|
if (!isDesktopSidebarMode(modalEl)) return;
|
||||||
|
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (sidebar.classList.contains('settings-sidebar-collapsed')) {
|
||||||
|
setSettingsSidebarCollapsed(modalEl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = sidebar.getBoundingClientRect().width;
|
||||||
|
const delta = event.key === 'ArrowLeft' ? -16 : 16;
|
||||||
|
|
||||||
|
// Once keyboard resizing reaches the declared minimum, another ArrowLeft
|
||||||
|
// collapses the rail. Without this explicit boundary transition the width
|
||||||
|
// setter clamps 134px back to 150px forever, making keyboard collapse via
|
||||||
|
// ArrowLeft unreachable.
|
||||||
|
if (
|
||||||
|
event.key === 'ArrowLeft'
|
||||||
|
&& current <= SETTINGS_SIDEBAR_MIN_WIDTH
|
||||||
|
) {
|
||||||
|
setSettingsSidebarCollapsed(modalEl, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettingsSidebarWidth(modalEl, current + delta);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1128,10 +1128,67 @@ function _renderTestLog(logEl, verdictEl, job, card, name) {
|
|||||||
else if (ev.type === 'agent_step') add('— round ' + ev.round + ' —', 'skill-test-round');
|
else if (ev.type === 'agent_step') add('— round ' + ev.round + ' —', 'skill-test-round');
|
||||||
else if (ev.type === 'tool_start') add('▸ ' + ev.tool + ' ' + String(ev.command || '').slice(0, 200), 'skill-test-tool');
|
else if (ev.type === 'tool_start') add('▸ ' + ev.tool + ' ' + String(ev.command || '').slice(0, 200), 'skill-test-tool');
|
||||||
else if (ev.type === 'tool_output') add(String(ev.output || '').slice(0, 500), 'skill-test-out');
|
else if (ev.type === 'tool_output') add(String(ev.output || '').slice(0, 500), 'skill-test-out');
|
||||||
|
else if (ev.type === 'approval_granted' || ev.type === 'approval_denied') add(ev.text || '', 'skill-test-meta');
|
||||||
else if (ev.type === 'say') add(ev.text || '', 'skill-test-say');
|
else if (ev.type === 'say') add(ev.text || '', 'skill-test-say');
|
||||||
else if (ev.type === 'evaluating') add('Evaluating run…', 'skill-test-meta');
|
else if (ev.type === 'evaluating') add('Evaluating run…', 'skill-test-meta');
|
||||||
else if (ev.type === 'error') add('Error: ' + (ev.error || 'run failed'), 'skill-test-err');
|
else if (ev.type === 'error') add('Error: ' + (ev.error || 'run failed'), 'skill-test-err');
|
||||||
}
|
}
|
||||||
|
if (job.status === 'awaiting_approval' && job.approval) {
|
||||||
|
const approval = job.approval;
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'skill-test-approval';
|
||||||
|
const question = document.createElement('div');
|
||||||
|
question.className = 'skill-test-meta';
|
||||||
|
question.textContent = approval.question || 'Allow this exact action once?';
|
||||||
|
box.appendChild(question);
|
||||||
|
if (approval.action) {
|
||||||
|
const action = document.createElement('pre');
|
||||||
|
action.className = 'skill-test-out';
|
||||||
|
action.textContent = [
|
||||||
|
approval.action.tool || 'tool',
|
||||||
|
approval.action.content || '',
|
||||||
|
Array.isArray(approval.action.effects)
|
||||||
|
? `Effects: ${approval.action.effects.join(', ')}`
|
||||||
|
: '',
|
||||||
|
approval.action.workspace ? `Workspace: ${approval.action.workspace}` : '',
|
||||||
|
approval.action.digest ? `Approval fingerprint: ${approval.action.digest}` : '',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
box.appendChild(action);
|
||||||
|
}
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'modal-footer';
|
||||||
|
const decide = async (decision) => {
|
||||||
|
actions.querySelectorAll('button').forEach(btn => { btn.disabled = true; });
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API}/api/skills/${encodeURIComponent(name)}/test-approval`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ approval_id: approval.approval_id, decision }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
await _testSkill(card, name, false);
|
||||||
|
} catch (error) {
|
||||||
|
add(`Approval failed: ${error.message || error}`, 'skill-test-err');
|
||||||
|
actions.querySelectorAll('button').forEach(btn => { btn.disabled = false; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const [decision, label, cls] of [
|
||||||
|
['deny', 'Deny', 'confirm-btn confirm-btn-secondary'],
|
||||||
|
['approve', 'Allow once', 'confirm-btn confirm-btn-primary'],
|
||||||
|
]) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = cls;
|
||||||
|
button.textContent = label;
|
||||||
|
button.addEventListener('click', () => decide(decision));
|
||||||
|
actions.appendChild(button);
|
||||||
|
}
|
||||||
|
box.appendChild(actions);
|
||||||
|
logEl.appendChild(box);
|
||||||
|
}
|
||||||
if (job.status === 'running') add('…running (you can close this — it keeps going)', 'skill-test-meta');
|
if (job.status === 'running') add('…running (you can close this — it keeps going)', 'skill-test-meta');
|
||||||
logEl.scrollTop = logEl.scrollHeight;
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
if (job.status === 'done' && job.verdict) _renderTestVerdict(verdictEl, job.verdict, card, name);
|
if (job.status === 'done' && job.verdict) _renderTestVerdict(verdictEl, job.verdict, card, name);
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import modelsModule from './models.js';
|
|||||||
import chatRenderer from './chatRenderer.js';
|
import chatRenderer from './chatRenderer.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import themeModule from './theme.js';
|
import themeModule from './theme.js';
|
||||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
import documentModule from './document.js?v=20260815approvalsave1';
|
||||||
import workspaceModule from './workspace.js';
|
import workspaceModule from './workspace.js';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import cookbookModule from './cookbook.js';
|
import cookbookModule from './cookbook.js';
|
||||||
import { EVAL_PROMPTS } from './compare/index.js';
|
import { EVAL_PROMPTS } from './compare/index.js';
|
||||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||||
|
import { getSettings } from './appConfig.js';
|
||||||
|
|
||||||
// ── Module state ──────────────────────────────────────────────────────
|
// ── Module state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2027,12 +2028,12 @@ async function _cmdUsage(args, ctx) {
|
|||||||
const messageCount = Number(session?.message_count || 0);
|
const messageCount = Number(session?.message_count || 0);
|
||||||
const totalTokens = Number(session?.total_tokens || 0);
|
const totalTokens = Number(session?.total_tokens || 0);
|
||||||
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
|
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
|
||||||
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
const cost = chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
||||||
const costLine = costTracked
|
const costLine = cost > 0
|
||||||
? (cost > 0
|
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
||||||
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
: costTracked
|
||||||
: 'Estimated local cost: unavailable or zero')
|
? 'Estimated local cost: unavailable or zero'
|
||||||
: 'Estimated local cost: not tracked for this endpoint';
|
: 'Estimated local cost: no billable usage recorded';
|
||||||
|
|
||||||
slashReply(`<pre>${[
|
slashReply(`<pre>${[
|
||||||
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
|
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
|
||||||
@@ -5220,8 +5221,7 @@ async function _cmdShortcuts(args, ctx) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' });
|
const settings = await getSettings();
|
||||||
const settings = await res.json();
|
|
||||||
if (settings.keybinds) {
|
if (settings.keybinds) {
|
||||||
keybinds = { ...keybinds, ...settings.keybinds };
|
keybinds = { ...keybinds, ...settings.keybinds };
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-18
@@ -10,6 +10,7 @@ import { topPortalZ } from './toolWindowZOrder.js';
|
|||||||
import { sortModelIds } from './modelSort.js';
|
import { sortModelIds } from './modelSort.js';
|
||||||
import { ordinalSuffix } from './util/ordinal.js';
|
import { ordinalSuffix } from './util/ordinal.js';
|
||||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
import { getSettings, invalidateSettings } from './appConfig.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -214,31 +215,31 @@ async function _fetchActions() {
|
|||||||
return _builtinActions;
|
return _builtinActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _urgentEmailSettings = null;
|
|
||||||
async function _fetchUrgentEmailSettings() {
|
async function _fetchUrgentEmailSettings() {
|
||||||
if (_urgentEmailSettings) return _urgentEmailSettings;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
return await getSettings();
|
||||||
_urgentEmailSettings = await res.json();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_urgentEmailSettings = { urgent_email_prompt: '' };
|
return { urgent_email_prompt: '' };
|
||||||
}
|
}
|
||||||
return _urgentEmailSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _saveUrgentEmailSettings(prompt) {
|
async function _saveUrgentEmailSettings(prompt) {
|
||||||
_urgentEmailSettings = {
|
try {
|
||||||
...(_urgentEmailSettings || {}),
|
await fetch('/api/auth/settings', {
|
||||||
urgent_email_prompt: prompt || '',
|
method: 'POST',
|
||||||
};
|
credentials: 'same-origin',
|
||||||
await fetch('/api/auth/settings', {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
method: 'POST',
|
body: JSON.stringify({
|
||||||
credentials: 'same-origin',
|
urgent_email_prompt: prompt || '',
|
||||||
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([
|
const _EMAIL_ACCOUNT_ACTIONS = new Set([
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,8 @@
|
|||||||
// static/js/tts-ai.js
|
// static/js/tts-ai.js
|
||||||
// AI Text-to-Speech Module — supports server TTS and browser Web Speech API
|
// AI Text-to-Speech Module — supports server TTS and browser Web Speech API
|
||||||
|
|
||||||
|
import { getSettings } from './appConfig.js';
|
||||||
|
|
||||||
class AITTSManager {
|
class AITTSManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.currentAudio = null;
|
this.currentAudio = null;
|
||||||
@@ -30,10 +32,11 @@ class AITTSManager {
|
|||||||
|
|
||||||
async checkAvailability() {
|
async checkAvailability() {
|
||||||
try {
|
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 {
|
try {
|
||||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
const settings = await getSettings();
|
||||||
const settings = await settingsRes.json();
|
|
||||||
if (settings.tts_enabled === false) {
|
if (settings.tts_enabled === false) {
|
||||||
this.available = false;
|
this.available = false;
|
||||||
this._provider = 'disabled';
|
this._provider = 'disabled';
|
||||||
|
|||||||
+36
-16
@@ -5,10 +5,29 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
|
||||||
<title>Odysseus — Login</title>
|
<title>Odysseus — Login</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="static/manifest.json">
|
||||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
|
||||||
<script nonce="{{CSP_NONCE}}">
|
<script nonce="{{CSP_NONCE}}">
|
||||||
(function(){
|
(function(){
|
||||||
|
function computeAppBasePath() {
|
||||||
|
var path = window.location.pathname || '/login';
|
||||||
|
if (path === '/login') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (path.endsWith('/login')) {
|
||||||
|
return path.slice(0, -'/login'.length).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
var appBasePath = computeAppBasePath();
|
||||||
|
window.__odysseusLoginBasePath = appBasePath;
|
||||||
|
window.__odysseusLoginAppUrl = function(path) {
|
||||||
|
var normalizedPath = String(path || '/');
|
||||||
|
if (!normalizedPath.startsWith('/')) {
|
||||||
|
normalizedPath = '/' + normalizedPath;
|
||||||
|
}
|
||||||
|
return appBasePath + normalizedPath;
|
||||||
|
};
|
||||||
// Per-theme bg-effect defaults — mirrors THEME_DEFAULT_* maps in
|
// Per-theme bg-effect defaults — mirrors THEME_DEFAULT_* maps in
|
||||||
// static/js/theme.js so login picks the same default pattern as the
|
// static/js/theme.js so login picks the same default pattern as the
|
||||||
// main app for users who never explicitly chose one.
|
// main app for users who never explicitly chose one.
|
||||||
@@ -85,8 +104,8 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||||
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
|
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
|
||||||
uncustomized theme — or a fresh browser with no `odysseus-theme` in
|
uncustomized theme — or a fresh browser with no `odysseus-theme` in
|
||||||
localStorage — renders the login page in the same palette as the rest
|
localStorage — renders the login page in the same palette as the rest
|
||||||
@@ -300,9 +319,10 @@
|
|||||||
|
|
||||||
<script nonce="{{CSP_NONCE}}">
|
<script nonce="{{CSP_NONCE}}">
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const appUrl = window.__odysseusLoginAppUrl || ((path) => path);
|
||||||
// Load version
|
// Load version
|
||||||
try {
|
try {
|
||||||
const vr = await fetch('/api/version');
|
const vr = await fetch(appUrl('/api/version'));
|
||||||
if (vr.ok) {
|
if (vr.ok) {
|
||||||
const vd = await vr.json();
|
const vd = await vr.json();
|
||||||
document.getElementById('version-label').textContent = 'v' + vd.version;
|
document.getElementById('version-label').textContent = 'v' + vd.version;
|
||||||
@@ -363,12 +383,12 @@
|
|||||||
|
|
||||||
// Check auth status and fetch policy in parallel, but don't block the
|
// Check auth status and fetch policy in parallel, but don't block the
|
||||||
// authenticated redirect on the policy response.
|
// authenticated redirect on the policy response.
|
||||||
const policyPromise = fetch('/api/auth/policy', { credentials: 'same-origin' }).catch(() => null);
|
const policyPromise = fetch(appUrl('/api/auth/policy'), { credentials: 'same-origin' }).catch(() => null);
|
||||||
try {
|
try {
|
||||||
const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
const statusRes = await fetch(appUrl('/api/auth/status'), { credentials: 'same-origin' });
|
||||||
const data = await statusRes.json();
|
const data = await statusRes.json();
|
||||||
if (data.authenticated) {
|
if (data.authenticated) {
|
||||||
window.location.replace('/');
|
window.location.replace(appUrl('/'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signupAllowed = !!data.signup_enabled;
|
signupAllowed = !!data.signup_enabled;
|
||||||
@@ -405,7 +425,7 @@
|
|||||||
if (!code) { totpInput.focus(); submitBtn.disabled = false; return; }
|
if (!code) { totpInput.focus(); submitBtn.disabled = false; return; }
|
||||||
const remember = document.getElementById('remember').checked;
|
const remember = document.getElementById('remember').checked;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch(appUrl('/api/auth/login'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -449,7 +469,7 @@
|
|||||||
|
|
||||||
// Setup or signup first
|
// Setup or signup first
|
||||||
if (mode === 'setup' || mode === 'signup') {
|
if (mode === 'setup' || mode === 'signup') {
|
||||||
const endpoint = mode === 'setup' ? '/api/auth/setup' : '/api/auth/signup';
|
const endpoint = mode === 'setup' ? appUrl('/api/auth/setup') : appUrl('/api/auth/signup');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -477,19 +497,19 @@
|
|||||||
submitBtn.innerHTML = '<span class="login-spinner" aria-hidden="true"></span>';
|
submitBtn.innerHTML = '<span class="login-spinner" aria-hidden="true"></span>';
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch('/api/sessions', { credentials: 'same-origin' }).then(r => r.json()),
|
fetch(appUrl('/api/sessions'), { credentials: 'same-origin' }).then(r => r.json()),
|
||||||
fetch('/api/auth/features', { credentials: 'same-origin' }).then(r => r.json()),
|
fetch(appUrl('/api/auth/features'), { credentials: 'same-origin' }).then(r => r.json()),
|
||||||
fetch('/api/auth/settings', { credentials: 'same-origin' }).then(r => r.json()),
|
fetch(appUrl('/api/auth/settings'), { credentials: 'same-origin' }).then(r => r.json()),
|
||||||
]).then(([sess, feat, sett]) => {
|
]).then(([sess, feat, sett]) => {
|
||||||
sessionStorage.setItem('ody-prefetch-sessions', JSON.stringify(sess));
|
sessionStorage.setItem('ody-prefetch-sessions', JSON.stringify(sess));
|
||||||
sessionStorage.setItem('ody-prefetch-features', JSON.stringify(feat));
|
sessionStorage.setItem('ody-prefetch-features', JSON.stringify(feat));
|
||||||
sessionStorage.setItem('ody-prefetch-settings', JSON.stringify(sett));
|
sessionStorage.setItem('ody-prefetch-settings', JSON.stringify(sett));
|
||||||
}).catch(() => {}).finally(() => { window.location.replace('/'); });
|
}).catch(() => {}).finally(() => { window.location.replace(appUrl('/')); });
|
||||||
}
|
}
|
||||||
async function doLogin(totpCode) {
|
async function doLogin(totpCode) {
|
||||||
const loginBody = { username, password, remember };
|
const loginBody = { username, password, remember };
|
||||||
if (totpCode) loginBody.totp_code = totpCode;
|
if (totpCode) loginBody.totp_code = totpCode;
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch(appUrl('/api/auth/login'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -587,7 +607,7 @@ if (window.visualViewport) {
|
|||||||
// `applyBgPattern` would never fire on its own. We call it directly
|
// `applyBgPattern` would never fire on its own. We call it directly
|
||||||
// here against the pattern the bootstrap already chose.
|
// here against the pattern the bootstrap already chose.
|
||||||
try {
|
try {
|
||||||
const tm = await import('/static/js/theme.js');
|
const tm = await import((window.__odysseusLoginAppUrl || ((path) => path))('/static/js/theme.js'));
|
||||||
const pattern = window.__loginBgPattern;
|
const pattern = window.__loginBgPattern;
|
||||||
if (pattern && tm.applyBgPattern) tm.applyBgPattern(pattern);
|
if (pattern && tm.applyBgPattern) tm.applyBgPattern(pattern);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+271
-8
@@ -9685,11 +9685,11 @@ body.fullwidth-chat .chat-history {
|
|||||||
.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
|
.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
/* -17px (was -15) nudges the terminating dot 2px further left so it
|
/* Centre the 6px dot on the rail: the rail sits at left:5px + 2px wide,
|
||||||
sits flush with the thread's left rail when the search node is
|
so its centre is 6px from the thread's left edge and the dot's left
|
||||||
expanded. This is the "big glow dot at the bottom" the user sees
|
edge belongs at 3px — i.e. 3px - padding-left. Keep this in step with
|
||||||
after a web_search step. */
|
.agent-thread-dot and with the mobile padding override below. */
|
||||||
left: -17px;
|
left: -19px;
|
||||||
bottom: 5px;
|
bottom: 5px;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -9958,6 +9958,11 @@ body.fullwidth-chat .chat-history {
|
|||||||
left: -16px;
|
left: -16px;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
}
|
}
|
||||||
|
/* padding-left drops to 18px here, so the terminating dot needs its own
|
||||||
|
offset (3px - 18px) to stay centred on the rail. */
|
||||||
|
.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
|
||||||
|
left: -15px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== AGENT TOOL OUTPUT (inside thread nodes) ===== */
|
/* ===== AGENT TOOL OUTPUT (inside thread nodes) ===== */
|
||||||
@@ -23600,7 +23605,7 @@ body.gallery-selecting .gallery-dl-btn,
|
|||||||
|
|
||||||
/* ===== Settings Modal Layout ===== */
|
/* ===== Settings Modal Layout ===== */
|
||||||
.settings-modal-content {
|
.settings-modal-content {
|
||||||
width: min(720px, 92vw);
|
width: min(1040px, 94vw);
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
@@ -23654,10 +23659,13 @@ body.gallery-selecting .gallery-dl-btn,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-sidebar {
|
.settings-sidebar {
|
||||||
width: 160px;
|
position: relative;
|
||||||
flex-shrink: 0;
|
width: var(--settings-sidebar-width, 220px);
|
||||||
|
flex: 0 0 var(--settings-sidebar-width, 220px);
|
||||||
|
min-width: 0;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
transition: width 0.16s ease, flex-basis 0.16s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -41136,3 +41144,258 @@ body.theme-frosted .modal {
|
|||||||
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
|
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
|
||||||
.compare-pane { min-height: 60dvh; }
|
.compare-pane { min-height: 60dvh; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Settings navigation / finder ────────────────────────────── */
|
||||||
|
.settings-sidebar-content {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-search {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 32px;
|
||||||
|
padding: 5px 9px 5px 29px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-search:focus {
|
||||||
|
border-color: var(--accent, var(--red));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-search::placeholder {
|
||||||
|
color: var(--fg);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 9px;
|
||||||
|
left: 9px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-search-results {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
min-width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-result:hover,
|
||||||
|
.settings-search-result.active {
|
||||||
|
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-result-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-result-group {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 92px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-empty {
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 0;
|
||||||
|
right: -3px;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: transparent;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-resize-handle:hover,
|
||||||
|
.settings-sidebar-resizing .settings-sidebar-resize-handle {
|
||||||
|
background: var(--accent, var(--red));
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-resizing {
|
||||||
|
transition: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.settings-sidebar-resize-active {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-toggle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 25;
|
||||||
|
top: 9px;
|
||||||
|
right: -13px;
|
||||||
|
display: flex;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar:hover .settings-sidebar-toggle,
|
||||||
|
.settings-sidebar-toggle:focus-visible,
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-toggle:hover,
|
||||||
|
.settings-sidebar-toggle:focus-visible {
|
||||||
|
border-color: var(--accent, var(--red));
|
||||||
|
color: var(--accent, var(--red));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-toggle-expand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed {
|
||||||
|
width: 34px;
|
||||||
|
flex-basis: 34px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-resize-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
|
||||||
|
top: 10px;
|
||||||
|
right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-collapse {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-expand {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The existing narrow/snapped layout is a horizontal tab rail. Sidebar
|
||||||
|
resizing/collapse applies only to the vertical desktop layout. */
|
||||||
|
@container settings-modal (max-width: 620px) {
|
||||||
|
.settings-sidebar {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-content {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-search-wrap,
|
||||||
|
.settings-sidebar-resize-handle,
|
||||||
|
.settings-sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed {
|
||||||
|
width: auto;
|
||||||
|
flex-basis: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.settings-sidebar {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-content {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-search-wrap,
|
||||||
|
.settings-sidebar-resize-handle,
|
||||||
|
.settings-sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+77
-5
@@ -7,16 +7,28 @@
|
|||||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||||
// - API / non-GET: never cached.
|
// - API / non-GET: never cached.
|
||||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||||
const CACHE_NAME = 'odysseus-v376-settings-title-icons';
|
const CACHE_NAME = 'odysseus-v378-shared-config-image-editor';
|
||||||
|
|
||||||
// Core shell precached on install so repeat opens are instant without any
|
// Two lists, two jobs — they are no longer the same set and must not be
|
||||||
// network wait. Keep this list in sync with the <script type="module"> tags
|
// "resynced" back into one:
|
||||||
// and <link rel="stylesheet"> in index.html.
|
//
|
||||||
|
// 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 = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/static/style.css',
|
'/static/style.css',
|
||||||
'/static/app.js',
|
'/static/app.js',
|
||||||
'/static/js/storage.js',
|
'/static/js/storage.js',
|
||||||
|
'/static/js/appConfig.js',
|
||||||
'/static/js/ui.js',
|
'/static/js/ui.js',
|
||||||
'/static/js/markdown.js',
|
'/static/js/markdown.js',
|
||||||
'/static/js/dragSort.js',
|
'/static/js/dragSort.js',
|
||||||
@@ -63,13 +75,73 @@ const PRECACHE = [
|
|||||||
'/static/lib/highlight.min.js',
|
'/static/lib/highlight.min.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
|
||||||
|
// precached so the panel still opens with no network.
|
||||||
|
const PANEL_PRECACHE = [
|
||||||
|
// Image editor — galleryEditor.js and its js/editor/ graph.
|
||||||
|
'/static/js/galleryEditor.js',
|
||||||
|
'/static/js/editor/ai-inpaint.js?v=20260708match1',
|
||||||
|
'/static/js/editor/ai-models.js',
|
||||||
|
'/static/js/editor/ai-rembg.js',
|
||||||
|
'/static/js/editor/ai-tool-runner.js',
|
||||||
|
'/static/js/editor/ai-tools-misc.js',
|
||||||
|
'/static/js/editor/build/controls.js?v=20260708match1',
|
||||||
|
'/static/js/editor/build/popups.js',
|
||||||
|
'/static/js/editor/build/right-panel.js',
|
||||||
|
'/static/js/editor/build/toolbar.js?v=20260708sam3',
|
||||||
|
'/static/js/editor/build/topbar.js',
|
||||||
|
'/static/js/editor/build/transform-popup.js',
|
||||||
|
'/static/js/editor/canvas-coords.js',
|
||||||
|
'/static/js/editor/canvas-events.js',
|
||||||
|
'/static/js/editor/canvas-transforms.js',
|
||||||
|
'/static/js/editor/checkerboard.js',
|
||||||
|
'/static/js/editor/clipboard-and-drop.js',
|
||||||
|
'/static/js/editor/composite-helpers.js',
|
||||||
|
'/static/js/editor/filters/blur.js',
|
||||||
|
'/static/js/editor/filters/edge-feather.js',
|
||||||
|
'/static/js/editor/fx/adj-popup.js',
|
||||||
|
'/static/js/editor/fx/filter-string.js',
|
||||||
|
'/static/js/editor/fx/histogram.js',
|
||||||
|
'/static/js/editor/fx/pixel-pass.js',
|
||||||
|
'/static/js/editor/harmonize-masks.js',
|
||||||
|
'/static/js/editor/history-panel.js',
|
||||||
|
'/static/js/editor/keyboard-shortcuts.js',
|
||||||
|
'/static/js/editor/layer-helpers.js',
|
||||||
|
'/static/js/editor/layer-panel.js',
|
||||||
|
'/static/js/editor/mask-utils.js',
|
||||||
|
'/static/js/editor/shortcuts-popover.js',
|
||||||
|
'/static/js/editor/slider-ux.js',
|
||||||
|
'/static/js/editor/snap.js',
|
||||||
|
'/static/js/editor/state.js',
|
||||||
|
'/static/js/editor/stroke-pipeline.js',
|
||||||
|
'/static/js/editor/stroke-tool-sliders.js',
|
||||||
|
'/static/js/editor/tools/clone.js',
|
||||||
|
'/static/js/editor/tools/crop.js',
|
||||||
|
'/static/js/editor/tools/flood-fill.js',
|
||||||
|
'/static/js/editor/tools/lasso-mask.js',
|
||||||
|
'/static/js/editor/tools/lasso.js',
|
||||||
|
'/static/js/editor/tools/move.js',
|
||||||
|
'/static/js/editor/tools/stroke.js',
|
||||||
|
'/static/js/editor/tools/transform-drag.js',
|
||||||
|
'/static/js/editor/tools/transform-handles.js',
|
||||||
|
'/static/js/editor/tools/transform-session.js',
|
||||||
|
'/static/js/editor/tools/wand.js',
|
||||||
|
'/static/js/editor/wire-import.js',
|
||||||
|
'/static/js/editor/wire-inpaint-controls.js?v=20260708match1',
|
||||||
|
'/static/js/editor/wire-merge-buttons.js',
|
||||||
|
'/static/js/editor/wire-selection-controls.js',
|
||||||
|
'/static/js/editor/wire-topbar-menus.js',
|
||||||
|
'/static/js/editor/wire-topbar-overflow.js',
|
||||||
|
'/static/js/editor/wire-topbar.js',
|
||||||
|
];
|
||||||
|
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
caches.open(CACHE_NAME).then(cache =>
|
caches.open(CACHE_NAME).then(cache =>
|
||||||
// addAll is atomic — if any item fails, none are cached. Use individual
|
// addAll is atomic — if any item fails, none are cached. Use individual
|
||||||
// puts so a single 404 can't block the whole install.
|
// puts so a single 404 can't block the whole install.
|
||||||
Promise.all(
|
Promise.all(
|
||||||
PRECACHE.map(url =>
|
[...PRECACHE, ...PANEL_PRECACHE].map(url =>
|
||||||
fetch(url, { cache: 'reload' })
|
fetch(url, { cache: 'reload' })
|
||||||
.then(res => res.ok ? cache.put(url, res) : null)
|
.then(res => res.ok ? cache.put(url, res) : null)
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
|
|||||||
@@ -0,0 +1,775 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
class ClassList {
|
||||||
|
constructor() { this.values = new Set(); }
|
||||||
|
add(...names) { names.filter(Boolean).forEach(name => this.values.add(name)); }
|
||||||
|
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
||||||
|
contains(name) { return this.values.has(name); }
|
||||||
|
toggle(name, force) {
|
||||||
|
if (force === undefined) force = !this.contains(name);
|
||||||
|
force ? this.add(name) : this.remove(name);
|
||||||
|
return force;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Style {
|
||||||
|
constructor() { this.values = {}; this.display = ''; this.cssText = ''; }
|
||||||
|
setProperty(name, value) { this.values[name] = value; this[name] = value; }
|
||||||
|
removeProperty(name) { delete this.values[name]; delete this[name]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor(tagName, documentRef) {
|
||||||
|
this.tagName = String(tagName || 'div').toUpperCase();
|
||||||
|
this.ownerDocument = documentRef;
|
||||||
|
this.children = [];
|
||||||
|
this.parentElement = null;
|
||||||
|
this.attributes = {};
|
||||||
|
this.dataset = {};
|
||||||
|
this.classList = new ClassList();
|
||||||
|
this.style = new Style();
|
||||||
|
this._listeners = new Map();
|
||||||
|
this._innerHTML = '';
|
||||||
|
this._textContent = '';
|
||||||
|
}
|
||||||
|
set id(value) { this.attributes.id = String(value); }
|
||||||
|
get id() { return this.attributes.id || ''; }
|
||||||
|
set className(value) {
|
||||||
|
this.classList.values = new Set(String(value || '').split(/\s+/).filter(Boolean));
|
||||||
|
}
|
||||||
|
get className() { return Array.from(this.classList.values).join(' '); }
|
||||||
|
set innerHTML(value) {
|
||||||
|
this._innerHTML = String(value || '');
|
||||||
|
if (!this._innerHTML) {
|
||||||
|
this.children.forEach(child => { child.parentElement = null; });
|
||||||
|
this.children = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get innerHTML() { return this._innerHTML; }
|
||||||
|
set textContent(value) {
|
||||||
|
this._textContent = String(value ?? '');
|
||||||
|
this.children.forEach(child => { child.parentElement = null; });
|
||||||
|
this.children = [];
|
||||||
|
}
|
||||||
|
get textContent() {
|
||||||
|
return this._textContent + this.children.map(child => child.textContent).join('');
|
||||||
|
}
|
||||||
|
setAttribute(name, value) {
|
||||||
|
const text = String(value);
|
||||||
|
this.attributes[name] = text;
|
||||||
|
if (name === 'class') this.className = text;
|
||||||
|
if (name.startsWith('data-')) {
|
||||||
|
const key = name.slice(5).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
||||||
|
this.dataset[key] = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getAttribute(name) { return this.attributes[name] ?? null; }
|
||||||
|
appendChild(child) {
|
||||||
|
child.parentElement = this;
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
append(...children) { children.forEach(child => this.appendChild(child)); }
|
||||||
|
replaceChildren(...children) {
|
||||||
|
this.children.forEach(child => { child.parentElement = null; });
|
||||||
|
this.children = [];
|
||||||
|
children.forEach(child => this.appendChild(child));
|
||||||
|
}
|
||||||
|
contains(candidate) {
|
||||||
|
if (candidate === this) return true;
|
||||||
|
return descendants(this).includes(candidate);
|
||||||
|
}
|
||||||
|
getBoundingClientRect() {
|
||||||
|
const width = Number.parseFloat(
|
||||||
|
this.style.values['--settings-sidebar-width']
|
||||||
|
|| this.style.width
|
||||||
|
// This is only a desktop-mode fixture (>620px), not a CSS assertion.
|
||||||
|
|| (this.classList.contains('settings-modal-content') ? '800' : '220'),
|
||||||
|
);
|
||||||
|
return { width, height: 600, left: 0, right: width, top: 0, bottom: 600 };
|
||||||
|
}
|
||||||
|
addEventListener(type, handler, options = {}) {
|
||||||
|
if (!this._listeners.has(type)) this._listeners.set(type, []);
|
||||||
|
this._listeners.get(type).push({ handler, once: !!options.once });
|
||||||
|
}
|
||||||
|
removeEventListener(type, handler) {
|
||||||
|
const entries = this._listeners.get(type) || [];
|
||||||
|
this._listeners.set(type, entries.filter(entry => entry.handler !== handler));
|
||||||
|
}
|
||||||
|
dispatchEvent(event) {
|
||||||
|
event.target ||= this;
|
||||||
|
event.currentTarget = this;
|
||||||
|
const entries = [...(this._listeners.get(event.type) || [])];
|
||||||
|
for (const entry of entries) {
|
||||||
|
entry.handler.call(this, event);
|
||||||
|
if (entry.once) this.removeEventListener(event.type, entry.handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
click() {
|
||||||
|
this.dispatchEvent({ type: 'click', preventDefault() {}, stopPropagation() {} });
|
||||||
|
}
|
||||||
|
matches(selector) { return matchesSelector(this, selector); }
|
||||||
|
querySelector(selector) { return queryAll(this, selector)[0] || null; }
|
||||||
|
querySelectorAll(selector) { return queryAll(this, selector); }
|
||||||
|
closest(selector) {
|
||||||
|
let node = this;
|
||||||
|
while (node) {
|
||||||
|
if (matchesSelector(node, selector)) return node;
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleMatch(element, selector) {
|
||||||
|
const text = selector.trim();
|
||||||
|
if (!text) return false;
|
||||||
|
const id = text.match(/#([\w-]+)/);
|
||||||
|
if (id && element.id !== id[1]) return false;
|
||||||
|
for (const match of text.matchAll(/\.([\w-]+)/g)) {
|
||||||
|
if (!element.classList.contains(match[1])) return false;
|
||||||
|
}
|
||||||
|
for (const match of text.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
|
||||||
|
const actual = element.getAttribute(match[1]);
|
||||||
|
if (actual === null) return false;
|
||||||
|
if (match[2] !== undefined && actual !== match[2]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSelector(element, selector) {
|
||||||
|
return selector.split(',').some(part => simpleMatch(element, part));
|
||||||
|
}
|
||||||
|
|
||||||
|
function descendants(root) {
|
||||||
|
const result = [];
|
||||||
|
const visit = node => {
|
||||||
|
for (const child of node.children || []) {
|
||||||
|
result.push(child);
|
||||||
|
visit(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(root);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAll(root, selector) {
|
||||||
|
const selectors = selector.split(',').map(item => item.trim()).filter(Boolean);
|
||||||
|
const nodes = descendants(root);
|
||||||
|
const result = [];
|
||||||
|
for (const candidate of nodes) {
|
||||||
|
if (selectors.some(sel => simpleMatch(candidate, sel))) result.push(candidate);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DocumentShim {
|
||||||
|
constructor() {
|
||||||
|
this.body = new Element('body', this);
|
||||||
|
this.head = new Element('head', this);
|
||||||
|
this.listeners = new Map();
|
||||||
|
}
|
||||||
|
createElement(tag) { return new Element(tag, this); }
|
||||||
|
getElementById(id) {
|
||||||
|
return [this.body, this.head, ...descendants(this.body), ...descendants(this.head)]
|
||||||
|
.find(node => node.id === id) || null;
|
||||||
|
}
|
||||||
|
querySelector(selector) {
|
||||||
|
return this.querySelectorAll(selector)[0] || null;
|
||||||
|
}
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return [...queryAll(this.body, selector), ...queryAll(this.head, selector)];
|
||||||
|
}
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
if (!this.listeners.has(type)) this.listeners.set(type, []);
|
||||||
|
this.listeners.get(type).push(handler);
|
||||||
|
}
|
||||||
|
removeEventListener(type, handler) {
|
||||||
|
this.listeners.set(type, (this.listeners.get(type) || []).filter(item => item !== handler));
|
||||||
|
}
|
||||||
|
dispatch(type, event) {
|
||||||
|
for (const handler of [...(this.listeners.get(type) || [])]) handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFixture(document) {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'settings-modal';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'modal-header';
|
||||||
|
modal.appendChild(header);
|
||||||
|
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.className = 'close-btn';
|
||||||
|
header.appendChild(close);
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'settings-modal-content modal-content';
|
||||||
|
modal.appendChild(content);
|
||||||
|
|
||||||
|
const nav = document.createElement('div');
|
||||||
|
nav.className = 'settings-sidebar';
|
||||||
|
content.appendChild(nav);
|
||||||
|
|
||||||
|
const sidebarToggle = document.createElement('button');
|
||||||
|
sidebarToggle.id = 'settings-sidebar-toggle';
|
||||||
|
nav.appendChild(sidebarToggle);
|
||||||
|
|
||||||
|
const sidebarHandle = document.createElement('div');
|
||||||
|
sidebarHandle.id = 'settings-sidebar-resize-handle';
|
||||||
|
nav.appendChild(sidebarHandle);
|
||||||
|
|
||||||
|
const sidebarContent = document.createElement('div');
|
||||||
|
sidebarContent.className = 'settings-sidebar-content';
|
||||||
|
nav.appendChild(sidebarContent);
|
||||||
|
|
||||||
|
const finder = document.createElement('div');
|
||||||
|
sidebarContent.appendChild(finder);
|
||||||
|
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.id = 'settings-nav-search';
|
||||||
|
finder.appendChild(searchInput);
|
||||||
|
|
||||||
|
const searchResults = document.createElement('div');
|
||||||
|
searchResults.id = 'settings-nav-search-results';
|
||||||
|
searchResults.classList.add('hidden');
|
||||||
|
finder.appendChild(searchResults);
|
||||||
|
|
||||||
|
const panels = document.createElement('div');
|
||||||
|
content.appendChild(panels);
|
||||||
|
|
||||||
|
const makeTab = (id, active = false) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.setAttribute('data-settings-tab', id);
|
||||||
|
if (active) button.classList.add('active');
|
||||||
|
sidebarContent.appendChild(button);
|
||||||
|
const panel = document.createElement('section');
|
||||||
|
panel.setAttribute('data-settings-panel', id);
|
||||||
|
if (!active) panel.classList.add('hidden');
|
||||||
|
panels.appendChild(panel);
|
||||||
|
return { button, panel };
|
||||||
|
};
|
||||||
|
|
||||||
|
const panelIds = [
|
||||||
|
'services',
|
||||||
|
'added-models',
|
||||||
|
'ai',
|
||||||
|
'search',
|
||||||
|
'integrations',
|
||||||
|
'email',
|
||||||
|
'reminders',
|
||||||
|
'appearance',
|
||||||
|
'shortcuts',
|
||||||
|
'account',
|
||||||
|
'tools',
|
||||||
|
'users',
|
||||||
|
'system',
|
||||||
|
];
|
||||||
|
|
||||||
|
const settingsPanels = Object.fromEntries(
|
||||||
|
panelIds.map((id, index) => [id, makeTab(id, index === 0)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modal,
|
||||||
|
header,
|
||||||
|
close,
|
||||||
|
content,
|
||||||
|
services: settingsPanels.services,
|
||||||
|
appearance: settingsPanels.appearance,
|
||||||
|
system: settingsPanels.system,
|
||||||
|
settingsPanels,
|
||||||
|
searchInput,
|
||||||
|
searchResults,
|
||||||
|
sidebar: nav,
|
||||||
|
sidebarToggle,
|
||||||
|
sidebarHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleSource(relativePath) {
|
||||||
|
let source = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../static/js/settings', relativePath),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The production files are real ES modules. This lightweight VM harness
|
||||||
|
// removes imports because dependencies are loaded into the same context,
|
||||||
|
// but each module still needs its own lexical scope so private const/let
|
||||||
|
// bindings do not collide across modules.
|
||||||
|
source = source.replace(/^\s*import[\s\S]*?;\s*$/gm, '');
|
||||||
|
|
||||||
|
// Preserve exported API on globalThis while keeping all non-exported
|
||||||
|
// bindings private inside the module block below.
|
||||||
|
source = source
|
||||||
|
.replace(
|
||||||
|
/\bexport\s+function\s+([A-Za-z_$][\w$]*)\s*\(/g,
|
||||||
|
'globalThis.$1 = function $1(',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=/g,
|
||||||
|
'globalThis.$1 =',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\bexport\s+let\s+([A-Za-z_$][\w$]*)\s*=/g,
|
||||||
|
'globalThis.$1 =',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\bexport\s+var\s+([A-Za-z_$][\w$]*)\s*=/g,
|
||||||
|
'globalThis.$1 =',
|
||||||
|
);
|
||||||
|
|
||||||
|
return `{\n${source}\n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(function runTests() {
|
||||||
|
const document = new DocumentShim();
|
||||||
|
const fixture = buildFixture(document);
|
||||||
|
const dragCalls = [];
|
||||||
|
const dockCalls = [];
|
||||||
|
const removedWindowListeners = [];
|
||||||
|
|
||||||
|
const storage = new Map();
|
||||||
|
const context = {
|
||||||
|
console,
|
||||||
|
document,
|
||||||
|
localStorage: {
|
||||||
|
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||||
|
setItem(key, value) { storage.set(key, String(value)); },
|
||||||
|
removeItem(key) { storage.delete(key); },
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
removeEventListener: (...args) => removedWindowListeners.push(args),
|
||||||
|
addEventListener() {},
|
||||||
|
},
|
||||||
|
makeWindowDraggable: (...args) => dragCalls.push(args),
|
||||||
|
clearDockSide: (...args) => dockCalls.push(args),
|
||||||
|
setTimeout: callback => { callback(); return 1; },
|
||||||
|
WeakSet,
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(moduleSource('registry.js'), context, { filename: 'registry.js' });
|
||||||
|
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
|
||||||
|
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
|
||||||
|
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
|
||||||
|
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
|
||||||
|
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const check = (test, pass, detail = '') => results.push({ test, pass: Boolean(pass), detail });
|
||||||
|
|
||||||
|
const registryPanelIds = vm.runInContext(
|
||||||
|
'SETTINGS_PANELS.map(panel => panel.id).join(",")',
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const registryGroupIds = vm.runInContext(
|
||||||
|
'SETTINGS_GROUPS.map(group => group.id).join(",")',
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings registry preserves the existing sidebar panel order',
|
||||||
|
registryPanelIds === [
|
||||||
|
'services',
|
||||||
|
'added-models',
|
||||||
|
'ai',
|
||||||
|
'search',
|
||||||
|
'integrations',
|
||||||
|
'email',
|
||||||
|
'reminders',
|
||||||
|
'appearance',
|
||||||
|
'shortcuts',
|
||||||
|
'account',
|
||||||
|
'tools',
|
||||||
|
'users',
|
||||||
|
'system',
|
||||||
|
].join(','),
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings registry defines the intended information-architecture groups',
|
||||||
|
registryGroupIds === [
|
||||||
|
'models',
|
||||||
|
'communications',
|
||||||
|
'experience',
|
||||||
|
'account',
|
||||||
|
'administration',
|
||||||
|
].join(','),
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings registry keeps services, models, integrations and admin panels on the existing admin controller',
|
||||||
|
['services', 'added-models', 'integrations', 'tools', 'users', 'system']
|
||||||
|
.every(id => context.isAdminManagedSettingsTab(id))
|
||||||
|
&& ['ai', 'search', 'email', 'reminders', 'appearance', 'shortcuts', 'account']
|
||||||
|
.every(id => !context.isAdminManagedSettingsTab(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings registry distinguishes admin-only visibility from admin-controlled routing',
|
||||||
|
['tools', 'users', 'system'].every(id => context.isAdminOnlySettingsTab(id))
|
||||||
|
&& ['services', 'added-models', 'integrations']
|
||||||
|
.every(id => !context.isAdminOnlySettingsTab(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings registry provides search metadata without owning search UI',
|
||||||
|
context.getSettingsPanelSearchText('appearance').includes('theme')
|
||||||
|
&& context.getSettingsPanelSearchText('email').includes('smtp')
|
||||||
|
&& context.getSettingsPanelSearchText('missing') === '',
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings registry exposes group membership in sidebar order',
|
||||||
|
context.getSettingsPanelsForGroup('communications')
|
||||||
|
.map(panel => panel.id)
|
||||||
|
.join(',') === 'integrations,email,reminders',
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings registry matches every production-style tab and panel in the DOM fixture',
|
||||||
|
context.getSettingsRegistryIssues(fixture.modal).length === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings search resolves metadata terms in registry order',
|
||||||
|
context.searchSettingsPanels('provider', { isAdmin: true })
|
||||||
|
.map(panel => panel.id)
|
||||||
|
.join(',') === 'services,added-models,search',
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings search excludes admin-only panels for non-admin users',
|
||||||
|
context.searchSettingsPanels('agent tools', { isAdmin: false }).length === 0
|
||||||
|
&& context.searchSettingsPanels('agent tools', { isAdmin: true })
|
||||||
|
.map(panel => panel.id)
|
||||||
|
.join(',') === 'tools',
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings search requires all query terms',
|
||||||
|
context.searchSettingsPanels('appearance theme', { isAdmin: false })
|
||||||
|
.map(panel => panel.id)
|
||||||
|
.join(',') === 'appearance',
|
||||||
|
);
|
||||||
|
|
||||||
|
let searchedPanel = null;
|
||||||
|
context.bindSettingsSearch(fixture.modal, {
|
||||||
|
isAdmin: () => false,
|
||||||
|
openPanel(tab) { searchedPanel = tab; },
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.searchInput.value = 'theme';
|
||||||
|
fixture.searchInput.dispatchEvent({
|
||||||
|
type: 'input',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings finder renders matching production registry results',
|
||||||
|
!fixture.searchResults.classList.contains('hidden')
|
||||||
|
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1
|
||||||
|
&& fixture.searchResults.querySelector('[data-settings-search-result]').dataset.settingsSearchResult === 'appearance',
|
||||||
|
);
|
||||||
|
|
||||||
|
const finderOutsideTarget = document.createElement('div');
|
||||||
|
fixture.content.appendChild(finderOutsideTarget);
|
||||||
|
|
||||||
|
fixture.modal.dispatchEvent({
|
||||||
|
type: 'mousedown',
|
||||||
|
target: finderOutsideTarget,
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings finder click-away hides results while retaining the query',
|
||||||
|
fixture.searchInput.value === 'theme'
|
||||||
|
&& fixture.searchResults.classList.contains('hidden'),
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.searchInput.dispatchEvent({
|
||||||
|
type: 'focus',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings finder refocus restores results for an unchanged retained query',
|
||||||
|
fixture.searchInput.value === 'theme'
|
||||||
|
&& !fixture.searchResults.classList.contains('hidden')
|
||||||
|
&& fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.searchInput.dispatchEvent({
|
||||||
|
type: 'keydown',
|
||||||
|
key: 'Enter',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings finder Enter opens the first result and resets the finder',
|
||||||
|
searchedPanel === 'appearance'
|
||||||
|
&& fixture.searchInput.value === ''
|
||||||
|
&& fixture.searchResults.classList.contains('hidden'),
|
||||||
|
);
|
||||||
|
|
||||||
|
searchedPanel = null;
|
||||||
|
fixture.searchInput.value = 'agent tools';
|
||||||
|
fixture.searchInput.dispatchEvent({
|
||||||
|
type: 'input',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings finder does not expose admin-only results to non-admin users',
|
||||||
|
fixture.searchResults.querySelectorAll('[data-settings-search-result]').length === 0
|
||||||
|
&& fixture.searchResults.textContent !== '',
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.searchInput.value = 'theme';
|
||||||
|
fixture.searchInput.dispatchEvent({
|
||||||
|
type: 'input',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
fixture.searchInput.dispatchEvent({
|
||||||
|
type: 'keydown',
|
||||||
|
key: 'Escape',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings finder Escape clears results without closing Settings',
|
||||||
|
fixture.searchInput.value === ''
|
||||||
|
&& fixture.searchResults.classList.contains('hidden'),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.setSettingsSidebarWidth(fixture.modal, 280);
|
||||||
|
check(
|
||||||
|
'Settings sidebar width is clamped and applied through the production controller',
|
||||||
|
fixture.sidebar.style.values['--settings-sidebar-width'] === '280px',
|
||||||
|
);
|
||||||
|
|
||||||
|
const widthStorageKey = 'odysseus-settings-sidebar-width';
|
||||||
|
|
||||||
|
storage.clear();
|
||||||
|
const firstRunSidebar = buildFixture(document);
|
||||||
|
context.bindSettingsSidebar(firstRunSidebar.modal);
|
||||||
|
check(
|
||||||
|
'Settings sidebar first bind uses the declared 220px default when storage is empty',
|
||||||
|
firstRunSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.clear();
|
||||||
|
storage.set(widthStorageKey, '276');
|
||||||
|
const storedSidebar = buildFixture(document);
|
||||||
|
context.bindSettingsSidebar(storedSidebar.modal);
|
||||||
|
check(
|
||||||
|
'Settings sidebar first bind restores a valid stored width',
|
||||||
|
storedSidebar.sidebar.style.values['--settings-sidebar-width'] === '276px',
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.clear();
|
||||||
|
storage.set(widthStorageKey, 'not-a-number');
|
||||||
|
const malformedSidebar = buildFixture(document);
|
||||||
|
context.bindSettingsSidebar(malformedSidebar.modal);
|
||||||
|
check(
|
||||||
|
'Settings sidebar first bind falls back to default for malformed storage',
|
||||||
|
malformedSidebar.sidebar.style.values['--settings-sidebar-width'] === '220px',
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.clear();
|
||||||
|
storage.set(widthStorageKey, '10');
|
||||||
|
const minimumSidebar = buildFixture(document);
|
||||||
|
context.bindSettingsSidebar(minimumSidebar.modal);
|
||||||
|
|
||||||
|
storage.clear();
|
||||||
|
storage.set(widthStorageKey, '999');
|
||||||
|
const maximumSidebar = buildFixture(document);
|
||||||
|
context.bindSettingsSidebar(maximumSidebar.modal);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'Settings sidebar first bind clamps stored widths to declared bounds',
|
||||||
|
minimumSidebar.sidebar.style.values['--settings-sidebar-width'] === '150px'
|
||||||
|
&& maximumSidebar.sidebar.style.values['--settings-sidebar-width'] === '340px',
|
||||||
|
);
|
||||||
|
|
||||||
|
context.setSettingsSidebarCollapsed(fixture.modal, true);
|
||||||
|
check(
|
||||||
|
'Settings sidebar collapse leaves the compact navigation rail state active',
|
||||||
|
fixture.sidebar.classList.contains('settings-sidebar-collapsed')
|
||||||
|
&& fixture.sidebarToggle.getAttribute('aria-label') === 'Expand settings navigation',
|
||||||
|
);
|
||||||
|
|
||||||
|
context.setSettingsSidebarCollapsed(fixture.modal, false, { width: 260 });
|
||||||
|
check(
|
||||||
|
'Settings sidebar expansion restores the requested expanded width',
|
||||||
|
!fixture.sidebar.classList.contains('settings-sidebar-collapsed')
|
||||||
|
&& fixture.sidebar.style.values['--settings-sidebar-width'] === '260px',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keyboard behavior must be exercised on a fixture that went through the
|
||||||
|
// real binding path; direct controller calls above intentionally do not bind
|
||||||
|
// event listeners.
|
||||||
|
storage.clear();
|
||||||
|
const keyboardSidebar = buildFixture(document);
|
||||||
|
context.bindSettingsSidebar(keyboardSidebar.modal);
|
||||||
|
|
||||||
|
context.setSettingsSidebarWidth(
|
||||||
|
keyboardSidebar.modal,
|
||||||
|
150,
|
||||||
|
{ persist: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
keyboardSidebar.sidebarHandle.dispatchEvent({
|
||||||
|
type: 'keydown',
|
||||||
|
key: 'ArrowLeft',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
check(
|
||||||
|
'ArrowLeft at the sidebar minimum collapses the rail instead of getting stuck at 150px',
|
||||||
|
keyboardSidebar.sidebar.classList.contains('settings-sidebar-collapsed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.setSettingsSidebarCollapsed(
|
||||||
|
keyboardSidebar.modal,
|
||||||
|
false,
|
||||||
|
{ width: 220, persist: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'resizable Settings separator exposes its current ARIA range and value',
|
||||||
|
keyboardSidebar.sidebarHandle.getAttribute('aria-valuemin') === '150'
|
||||||
|
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuemax') === '340'
|
||||||
|
&& keyboardSidebar.sidebarHandle.getAttribute('aria-valuenow') === '220',
|
||||||
|
);
|
||||||
|
|
||||||
|
check('byId resolves elements through the production DOM helper', context.byId('settings-modal') === fixture.modal);
|
||||||
|
|
||||||
|
context.activateSettingsPanel(fixture.modal, 'appearance');
|
||||||
|
check(
|
||||||
|
'activateSettingsPanel switches sidebar and panel state together',
|
||||||
|
fixture.appearance.button.classList.contains('active')
|
||||||
|
&& !fixture.appearance.panel.classList.contains('hidden')
|
||||||
|
&& !fixture.services.button.classList.contains('active')
|
||||||
|
&& fixture.services.panel.classList.contains('hidden'),
|
||||||
|
);
|
||||||
|
check('getActiveSettingsTab reports the active panel', context.getActiveSettingsTab(fixture.modal) === 'appearance');
|
||||||
|
|
||||||
|
let activated = null;
|
||||||
|
let delegated = null;
|
||||||
|
context.bindSettingsNavigation(fixture.modal, {
|
||||||
|
openAdminTab(tab) { delegated = tab; return tab === 'system'; },
|
||||||
|
onPanelActivated(tab) { activated = tab; },
|
||||||
|
});
|
||||||
|
fixture.services.button.click();
|
||||||
|
check(
|
||||||
|
'normal navigation activates locally and notifies the coordinator',
|
||||||
|
activated === 'services' && fixture.services.button.classList.contains('active'),
|
||||||
|
);
|
||||||
|
activated = null;
|
||||||
|
fixture.system.button.click();
|
||||||
|
check(
|
||||||
|
'admin navigation delegates without performing a second local activation',
|
||||||
|
delegated === 'system' && activated === null && fixture.services.button.classList.contains('active'),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.bindSettingsDrag(fixture.modal);
|
||||||
|
check(
|
||||||
|
'drag binding preserves the existing Settings drag contract',
|
||||||
|
dragCalls.length === 1
|
||||||
|
&& dragCalls[0][0] === fixture.modal
|
||||||
|
&& dragCalls[0][1].content === fixture.content
|
||||||
|
&& dragCalls[0][1].header === fixture.header
|
||||||
|
&& dragCalls[0][1].enableDock === true
|
||||||
|
&& dragCalls[0][1].skipSelector === 'button, input, select, .theme-opacity-wrap',
|
||||||
|
);
|
||||||
|
|
||||||
|
let disconnected = 0;
|
||||||
|
fixture.modal.classList.add('modal-left-docked');
|
||||||
|
fixture.content.style.setProperty('left', '123px');
|
||||||
|
fixture.content.dataset._tileZone = 'left';
|
||||||
|
fixture.content._leftDockNavObs = {
|
||||||
|
navObs: { disconnect() { disconnected += 1; } },
|
||||||
|
reanchor() {},
|
||||||
|
};
|
||||||
|
context.resetSettingsWindowPlacement(fixture.modal);
|
||||||
|
check(
|
||||||
|
'window placement reset clears docking observers and inline placement',
|
||||||
|
!fixture.modal.classList.contains('modal-left-docked')
|
||||||
|
&& dockCalls.some(call => call[0] === 'left' && call[1] === fixture.modal)
|
||||||
|
&& disconnected === 1
|
||||||
|
&& !('_tileZone' in fixture.content.dataset)
|
||||||
|
&& fixture.content.style.left === undefined
|
||||||
|
&& removedWindowListeners.some(call => call[0] === 'resize'),
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.modal.classList.add('modal-right-docked', 'hidden');
|
||||||
|
context.showSettingsModal(fixture.modal);
|
||||||
|
check(
|
||||||
|
'showSettingsModal restores a hidden modal before showing it',
|
||||||
|
!fixture.modal.classList.contains('hidden')
|
||||||
|
&& !fixture.modal.classList.contains('modal-right-docked')
|
||||||
|
&& dockCalls.some(call => call[0] === 'right'),
|
||||||
|
);
|
||||||
|
|
||||||
|
let closeCount = 0;
|
||||||
|
context.bindSettingsClose(fixture.modal, {
|
||||||
|
closeSettings() { closeCount += 1; },
|
||||||
|
isTouchInsideModal() { return false; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.createElement('div');
|
||||||
|
form.id = 'unified-intg-form';
|
||||||
|
form.style.display = '';
|
||||||
|
form.appendChild(document.createElement('input'));
|
||||||
|
fixture.content.appendChild(form);
|
||||||
|
document.dispatch('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'Escape closes an inner integration editor before closing Settings',
|
||||||
|
form.style.display === 'none' && form.children.length === 0 && closeCount === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
document.dispatch('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
check('Escape closes Settings when no nested flow is active', closeCount === 1);
|
||||||
|
|
||||||
|
const popover = document.createElement('div');
|
||||||
|
popover.setAttribute('data-popover-open', '1');
|
||||||
|
popover.style.display = 'block';
|
||||||
|
fixture.content.appendChild(popover);
|
||||||
|
document.dispatch('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
preventDefault() {},
|
||||||
|
stopPropagation() {},
|
||||||
|
});
|
||||||
|
check('Escape leaves Settings open while a transient popover is active', closeCount === 1);
|
||||||
|
popover.classList.add('hidden');
|
||||||
|
|
||||||
|
context.hideSettingsModal(fixture.modal);
|
||||||
|
check(
|
||||||
|
'hideSettingsModal preserves the closing animation fallback semantics',
|
||||||
|
fixture.modal.classList.contains('hidden') && !fixture.content.classList.contains('modal-closing'),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify(results));
|
||||||
|
if (results.some(result => !result.pass)) process.exitCode = 1;
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,394 @@
|
|||||||
|
"""Regression guard for #5558 — POST /api/personal/add_directory must not run
|
||||||
|
the indexing job on the event loop.
|
||||||
|
|
||||||
|
The handler is ``async def`` but called ``rag.index_personal_documents``
|
||||||
|
(os.walk + file reads + per-chunk embedding + Chroma inserts) inline, so
|
||||||
|
FastAPI ran the whole job on the event loop and every other request queued
|
||||||
|
behind it: indexing a real directory froze the UI and API for 25+ minutes.
|
||||||
|
``personal_docs_manager.add_directory`` sits in the same blocking section — it
|
||||||
|
triggers ``refresh_index()``, which re-extracts text across tracked dirs.
|
||||||
|
|
||||||
|
These tests build the real router with fake managers and compare the thread
|
||||||
|
the indexing work runs on against the event loop's thread.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _serialization_probe():
|
||||||
|
"""Shared counter proving two critical sections never overlap."""
|
||||||
|
state = {"active": 0, "max_active": 0}
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def enter():
|
||||||
|
with lock:
|
||||||
|
state["active"] += 1
|
||||||
|
state["max_active"] = max(state["max_active"], state["active"])
|
||||||
|
|
||||||
|
def leave():
|
||||||
|
with lock:
|
||||||
|
state["active"] -= 1
|
||||||
|
|
||||||
|
return state, enter, leave
|
||||||
|
|
||||||
|
|
||||||
|
# Concurrency tests are `async def` (pyproject asyncio_mode="auto") and drive the
|
||||||
|
# ASGI app through httpx.ASGITransport + AsyncClient + asyncio.gather, NOT starlette
|
||||||
|
# TestClient + ThreadPoolExecutor: the job lock is an asyncio.Lock acquired in the
|
||||||
|
# async handler, and TestClient's portal-thread dispatch deadlocks against it (same
|
||||||
|
# reason test_notes_fail_closed_auth.py uses ASGITransport). asyncio.gather runs both
|
||||||
|
# requests on the test's own loop.
|
||||||
|
def _async_client(app):
|
||||||
|
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t")
|
||||||
|
|
||||||
|
import routes.personal_routes as personal_routes
|
||||||
|
from core.middleware import require_admin
|
||||||
|
from src.auth_helpers import require_user
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRag:
|
||||||
|
def __init__(self, record):
|
||||||
|
self._record = record
|
||||||
|
|
||||||
|
def index_personal_documents(self, directory, owner=None):
|
||||||
|
self._record["index_thread"] = threading.get_ident()
|
||||||
|
return {"success": True, "indexed_count": 3, "failed_count": 0}
|
||||||
|
|
||||||
|
def _split_into_chunks(self, text, chunk_size=500):
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
def add_document(self, chunk, metadata):
|
||||||
|
self._record["add_document_thread"] = threading.get_ident()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_by_source(self, filepath):
|
||||||
|
self._record["delete_thread"] = threading.get_ident()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDocsManager:
|
||||||
|
def __init__(self, record):
|
||||||
|
self._record = record
|
||||||
|
self.index = []
|
||||||
|
|
||||||
|
def add_directory(self, directory, *, index=True, owner=None):
|
||||||
|
self._record["bookkeeping_thread"] = threading.get_ident()
|
||||||
|
self._record["bookkeeping_index_flag"] = index
|
||||||
|
|
||||||
|
def exclude_file(self, filepath):
|
||||||
|
self._record["exclude_thread"] = threading.get_ident()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(tmp_path, monkeypatch, record):
|
||||||
|
monkeypatch.setattr(personal_routes, "PERSONAL_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(personal_routes, "get_rag_manager", lambda: _FakeRag(record))
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(
|
||||||
|
personal_routes.setup_personal_routes(_FakeDocsManager(record), None, True)
|
||||||
|
)
|
||||||
|
app.dependency_overrides[require_user] = lambda: "tester"
|
||||||
|
app.dependency_overrides[require_admin] = lambda: None
|
||||||
|
|
||||||
|
@app.get("/loop-thread")
|
||||||
|
async def loop_thread_probe():
|
||||||
|
return {"thread": threading.get_ident()}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_indexing_runs_off_the_event_loop(tmp_path, monkeypatch):
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
target = tmp_path / "docs"
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
# Context-manager client: one portal/event loop serves both requests, so
|
||||||
|
# the probe and the POST are guaranteed to see the same loop thread.
|
||||||
|
with TestClient(app) as client:
|
||||||
|
loop_thread = client.get("/loop-thread").json()["thread"]
|
||||||
|
resp = client.post(
|
||||||
|
"/api/personal/add_directory", json={"directory": str(target)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert record["index_thread"] != loop_thread, (
|
||||||
|
"index_personal_documents ran on the event loop thread — every other "
|
||||||
|
"request queues behind the indexing job (#5558)"
|
||||||
|
)
|
||||||
|
assert record["bookkeeping_thread"] != loop_thread, (
|
||||||
|
"personal_docs_manager.add_directory (refresh_index) ran on the event "
|
||||||
|
"loop thread"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_and_bookkeeping_unchanged(tmp_path, monkeypatch):
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
target = tmp_path / "docs"
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert body["indexed_count"] == 3
|
||||||
|
assert body["failed_count"] == 0
|
||||||
|
assert body["directory"] == os.path.realpath(str(target))
|
||||||
|
assert record["bookkeeping_index_flag"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_add_directory_requests_serialize_indexing(tmp_path, monkeypatch):
|
||||||
|
"""Off-loop execution must not mean parallel index jobs: concurrent
|
||||||
|
requests would race PersonalDocsManager's unsynchronized list mutations
|
||||||
|
and file writes (save_directories/_save_excluded are plain open('w'))."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
state, enter, leave = _serialization_probe()
|
||||||
|
|
||||||
|
def _slow_index(self, directory, owner=None):
|
||||||
|
enter(); time.sleep(0.2); leave()
|
||||||
|
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||||
|
|
||||||
|
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
for name in ("docs_a", "docs_b"):
|
||||||
|
(tmp_path / name).mkdir()
|
||||||
|
|
||||||
|
async with _async_client(app) as ac:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||||
|
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_b")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(r.status_code == 200 for r in results)
|
||||||
|
assert state["max_active"] == 1, (
|
||||||
|
f"{state['max_active']} index jobs ran in parallel — concurrent "
|
||||||
|
"add_directory requests must serialize"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_indexing_still_returns_500(tmp_path, monkeypatch):
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
target = tmp_path / "docs"
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
def _fail(directory, owner=None):
|
||||||
|
return {"success": False, "message": "boom"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(_FakeRag, "index_personal_documents", staticmethod(_fail))
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert "boom" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_and_remove_serialize(tmp_path, monkeypatch):
|
||||||
|
"""#5634: remove must hold the SAME job lock as add. Otherwise a remove
|
||||||
|
running while an add job is in flight races PersonalDocsManager's
|
||||||
|
unsynchronized list/index mutations — the inconsistent state the PR's
|
||||||
|
'add/remove are serialized' guarantee claims to prevent."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
state, enter, leave = _serialization_probe()
|
||||||
|
|
||||||
|
def _slow_index(self, directory, owner=None):
|
||||||
|
enter(); time.sleep(0.25); leave()
|
||||||
|
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||||
|
|
||||||
|
def _slow_remove(self, directory):
|
||||||
|
enter(); time.sleep(0.25); leave()
|
||||||
|
|
||||||
|
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||||
|
monkeypatch.setattr(_FakeDocsManager, "remove_directory", _slow_remove, raising=False)
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
(tmp_path / "docs_a").mkdir()
|
||||||
|
(tmp_path / "docs_b").mkdir()
|
||||||
|
|
||||||
|
async with _async_client(app) as ac:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||||
|
ac.delete("/api/personal/remove_directory", params={"directory": str(tmp_path / "docs_b")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(r.status_code == 200 for r in results)
|
||||||
|
assert state["max_active"] == 1, (
|
||||||
|
f"{state['max_active']} add/remove critical sections overlapped — "
|
||||||
|
"remove must hold the same index job lock as add"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_and_upload_serialize(tmp_path, monkeypatch):
|
||||||
|
"""#5634 follow-up: POST /upload writes chunks into the vector store and then
|
||||||
|
calls personal_docs_manager.add_directory — the same vector/tracking state
|
||||||
|
add_directory mutates. It must hold the SAME job lock, or an upload landing
|
||||||
|
mid-add interleaves two writers over unsynchronized state."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
state, enter, leave = _serialization_probe()
|
||||||
|
|
||||||
|
def _slow_index(self, directory, owner=None):
|
||||||
|
enter(); time.sleep(0.25); leave()
|
||||||
|
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||||
|
|
||||||
|
def _slow_add_document(self, chunk, metadata):
|
||||||
|
self._record["add_document_thread"] = threading.get_ident()
|
||||||
|
enter(); time.sleep(0.25); leave()
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||||
|
monkeypatch.setattr(_FakeRag, "add_document", _slow_add_document)
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||||
|
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||||
|
(tmp_path / "docs_a").mkdir()
|
||||||
|
|
||||||
|
async with _async_client(app) as ac:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||||
|
ac.post("/api/personal/upload", files={"files": ("a.txt", b"hello world", "text/plain")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(r.status_code == 200 for r in results)
|
||||||
|
# The test coroutine runs on the event loop, so this IS the loop thread.
|
||||||
|
assert record["add_document_thread"] != threading.get_ident(), (
|
||||||
|
"rag.add_document ran on the event loop thread — chunk writes block "
|
||||||
|
"every other request for the duration of the upload"
|
||||||
|
)
|
||||||
|
assert state["max_active"] == 1, (
|
||||||
|
f"{state['max_active']} add/upload critical sections overlapped — "
|
||||||
|
"upload must hold the same index job lock as add"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_processes_each_payload_before_reading_the_next(tmp_path, monkeypatch):
|
||||||
|
"""A multi-file upload must retain at most one capped payload at a time."""
|
||||||
|
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||||
|
|
||||||
|
reads = []
|
||||||
|
original_read = StarletteUploadFile.read
|
||||||
|
|
||||||
|
async def _recording_read(upload, size=-1):
|
||||||
|
reads.append(upload.filename)
|
||||||
|
return await original_read(upload, size)
|
||||||
|
|
||||||
|
def _record_first_index(self, chunk, metadata):
|
||||||
|
self._record.setdefault("reads_at_first_index", len(reads))
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(StarletteUploadFile, "read", _recording_read)
|
||||||
|
monkeypatch.setattr(_FakeRag, "add_document", _record_first_index)
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||||
|
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
("files", ("a.txt", b"alpha", "text/plain")),
|
||||||
|
("files", ("b.txt", b"bravo", "text/plain")),
|
||||||
|
("files", ("c.txt", b"charlie", "text/plain")),
|
||||||
|
]
|
||||||
|
async with _async_client(app) as ac:
|
||||||
|
response = await ac.post("/api/personal/upload", files=files)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["uploaded"] == ["a.txt", "b.txt", "c.txt"]
|
||||||
|
assert reads == ["a.txt", "b.txt", "c.txt"]
|
||||||
|
assert record["reads_at_first_index"] == 1, (
|
||||||
|
"all upload bodies were retained before worker processing began"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_and_delete_file_serialize(tmp_path, monkeypatch):
|
||||||
|
"""#5634 follow-up: DELETE /file removes chunks from the vector store and
|
||||||
|
calls personal_docs_manager.exclude_file. Both mutate state add_directory
|
||||||
|
also touches, so the delete must hold the SAME job lock as add."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
state, enter, leave = _serialization_probe()
|
||||||
|
|
||||||
|
def _slow_index(self, directory, owner=None):
|
||||||
|
enter(); time.sleep(0.25); leave()
|
||||||
|
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||||
|
|
||||||
|
def _slow_delete(self, filepath):
|
||||||
|
self._record["delete_thread"] = threading.get_ident()
|
||||||
|
enter(); time.sleep(0.25); leave()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||||
|
monkeypatch.setattr(_FakeRag, "delete_by_source", _slow_delete)
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||||
|
(tmp_path / "docs_a").mkdir()
|
||||||
|
doomed = tmp_path / "doomed.txt"
|
||||||
|
doomed.write_text("bye")
|
||||||
|
|
||||||
|
async with _async_client(app) as ac:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||||
|
ac.delete("/api/personal/file", params={"filepath": str(doomed)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(r.status_code == 200 for r in results)
|
||||||
|
assert record["delete_thread"] != threading.get_ident(), (
|
||||||
|
"rag.delete_by_source ran on the event loop thread"
|
||||||
|
)
|
||||||
|
assert state["max_active"] == 1, (
|
||||||
|
f"{state['max_active']} add/delete critical sections overlapped — "
|
||||||
|
"delete must hold the same index job lock as add"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reload_serializes_with_add(tmp_path, monkeypatch):
|
||||||
|
"""#5634: POST /reload rebuilds the index via refresh_index(); it must hold
|
||||||
|
the same job lock so it cannot race an in-flight add job."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
state, enter, leave = _serialization_probe()
|
||||||
|
|
||||||
|
def _slow_index(self, directory, owner=None):
|
||||||
|
enter(); time.sleep(0.25); leave()
|
||||||
|
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||||
|
|
||||||
|
def _slow_refresh(self):
|
||||||
|
enter(); time.sleep(0.25); leave()
|
||||||
|
|
||||||
|
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||||
|
monkeypatch.setattr(_FakeDocsManager, "refresh_index", _slow_refresh, raising=False)
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
app = _build_app(tmp_path, monkeypatch, record)
|
||||||
|
(tmp_path / "docs_a").mkdir()
|
||||||
|
|
||||||
|
async with _async_client(app) as ac:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||||
|
ac.post("/api/personal/reload"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(r.status_code == 200 for r in results)
|
||||||
|
assert state["max_active"] == 1, (
|
||||||
|
f"{state['max_active']} add/reload critical sections overlapped — "
|
||||||
|
"reload must hold the same index job lock as add"
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user