Compare commits

...
Author SHA1 Message Date
RaresKeY eb8842526b chore(release): align dev version with 1.0.3
Keep dev version metadata aligned with the current hotfix release while the rolling branch continues toward 1.1.0.

Evidence: the canonical APP_VERSION imports as 1.0.3 and the diff check is clean. This commit changes version metadata only; it does not tag or publish a release.
2026-08-25 10:24:19 +01:00
nopoz d0d8edf5d8 Merge commit from fork
scripts/mlx_image_server.py resolved the model per request
(`req.model or _args.model`) on both /v1/images/generations and
/v1/images/edits, so the caller chose which model was served.

`_is_hidream()` is a substring test and `_snapshot_path()` accepts either a
local directory or a Hugging Face repo id, so a caller-supplied string
selected the HiDream branch and then supplied the directory it runs
`scripts/hidream_o1/generate_hidream_o1_mlx.py` from, under sys.executable.
The server has no auth, and the Cookbook binds it to 0.0.0.0 whenever it is
serving to a remote host, so one POST executed attacker code on the serving
host.

Both paths now use `_args.model`. The request field is still accepted for
OpenAI wire compatibility and ignored, matching scripts/diffusion_server.py,
and Odysseus already sends the served model's own id, so this is a no-op for
legitimate callers. /v1/images/harmonize already pinned.

Regression tests cover both endpoints, the local-directory and
Hugging-Face-repo halves, and that a server actually launched with a HiDream
model still serves it. Three of the four fail on the unfixed code.
2026-08-24 17:38:40 +02:00
Joeseph Grey b4d12932a9 fix(agent): drop the empty assistant turn from an approved-action replay (#6124)
The approved-action replay appends the sealed tool result with no assistant
prose for that round, which produced an assistant message with content "".
Anthropic's Messages API rejects a non-final assistant message with empty
content, so a resumed turn after a tool approval failed before the model saw
the result. A turn carrying neither prose nor reasoning has nothing to say to
any provider, so it is no longer appended. A round with prose, and a
reasoning-only round that DeepSeek thinking mode needs, both still append.
2026-08-20 13:06:22 +02:00
Nikhil Chaudhary 85297cee44 fix(core): clean up orphaned temp files on atomic write failure (#6068)
* fix(core): clean up orphaned temp files on atomic write failure

* fixed reviewer suggestion

* removed whitespace
2026-08-19 17:38:24 +02:00
RaresKeYandLéo 981652358e fix(agent): allow remaining actions for an approved task (#6113)
* fix(agent): allow remaining actions for an approved task

* fix(agent): make approval continuation control-only

* fix(ci): preserve approval taint and cache-buster contract

* fix(ui): keep tool approvals in current chat

* fix(ui): route tool approvals through chat submit

* test(ui): pin approval submit routing

* fix(agent): complete approval denial flow

* fix(ui): avoid duplicate ask-user close icon

* fix(agent): retain approved tool in continuation set

* revert(ui): keep PR 6113 scoped to approval continuation

* fix(agent): add task and chat approval scopes

* fix(ui): prevent duplicate ask-user close icon

* feat(ui): add ask-user option shortcuts

* fix(compare): route ask-user choices per pane

* fix(agent): keep skill-test approvals to a single action

The chat card now reuses the wire value `approve` to mean chat-session
scope, and `consume()` returned `allow_remaining_actions=True` for it
unconditionally. The skill-test approval route was never updated: it still
sends `approve` meaning "once", and its button still reads "Allow once",
but the grant it got back set `approval_gate_bypassed` for the rest of the
resumed run. That surface wraps the skill body and every transcript byte
as untrusted context, so it is the last place where one click should
ungate everything that follows.

Give `consume()` an explicit `allow_continuation` flag. Callers that own a
resumable chat keep the scope the user picked; callers that do not — the
skill tester, unattended audits — get SINGLE_ACTION and the gate re-arms
behind the sealed action, which is what their label promises.

* fix(ui): cache-bust every module the approval click depends on

chatStream.js, compare/index.js and compare/stream.js all changed
behaviour but kept their old `?v=`, while chat.js and chatRenderer.js were
bumped. A returning browser therefore serves the new chat.js — which now
deliberately leaves the composer empty and clicks the send button — next to
the cached chatStream.js that has no interceptor. With an empty composer
that button sits at `data-mode="newchat"`, so the click opens a new chat
and the approval is dropped.

Bump the three, and version compare/stream.js's chatRenderer import to
match everyone else's so the ask_user keydown listener binds to one module
instance instead of two.

* fix(ui): keep the digit shortcuts off tool approval cards

With an approval card on screen and focus anywhere outside an input, a bare
`1` fired `approve_task` — the widest of the three grants — with no
modifier and no confirmation. That card is the one control whose entire
purpose is deliberate consent after untrusted context influenced the run,
and Deny sits at 3.

Label the card with its kind and skip the shortcut for approvals. Ordinary
ask_user questions keep 1-3.

* fix(compare): restore a pane's ask_user card instead of dropping the choice

renderAskUserCard removes the card as soon as onSubmit accepts, but the
resume loop gave up silently after 10s if the originating stream still owned
the pane. The user saw the click land, the card vanish, and nothing happen,
with no way to get it back.

Re-render the card on that deadline and say why. The reroll case still
returns without sending — that choice belongs to a stream that no longer
exists.

* refactor(chat): drop the unreachable deny branch

`if decision != "deny"` is always true — the deny path returns a
StreamingResponse a few lines above. It reads as if deny still falls
through to the toggle restore.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-19 08:01:34 -06:00
Utkarsh AdhranandRaresKeY 5c835014ac fix(time): prefer IANA timezone name over offset (#6122)
* fix(time): prefer IANA timezone name over offset

When both headers are present, resolve x-tz-name with ZoneInfo and ignore
a conflicting numeric offset. The prompt label uses the resolved zone so
name and UTC offset cannot disagree.

Related: #6111

* test(calendar): cover IANA timezone precedence

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-08-19 12:56:07 +02:00
Dividesbyzer0 43682d4e2e fix(cookbook): activate local Windows venv in bash runner (#5734) 2026-08-18 16:19:33 +02:00
RaresKeY 032967af4b fix(models): show API models by default (#6089) 2026-08-17 13:41:04 +02:00
RaresKeY 0e03aea134 fix(models): align API model checkbox state (#6087) 2026-08-17 11:09:02 +01:00
Joeseph Grey 2a6b09b968 Merge pull request #6081 from ydonghao/refactor/routes-task-to-subdir
refactor(routes): move task domain into routes/task/ subpackage
2026-08-16 22:29:43 -06:00
yuandonghao 1a2d889c33 refactor(routes): move task domain into routes/task/ subpackage
Slice 2p of the route-domain reorganization (#4082/#4071). Moves
task_routes.py (1181 lines) into routes/task/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.

The shim uses sys.modules replacement so the `import ... as task_routes` +
`monkeypatch.setattr(task_routes, "SessionLocal", ...)` /
`"get_current_user"` pattern and the `task_routes.__file__` reads in
test_auth_regressions.py all reach the canonical module.

Four source-introspection test sites repointed:
- test_aux_llm_owner_scope.py
- test_model_helper_owner_scope.py
- test_internal_api_base.py
- test_webhook_trigger_auth_exempt.py

Adds tests/test_task_routes_shim.py to pin the sys.modules shim contract.

Verified: compileall clean; full suite 5040 passed, 3 skipped.
2026-08-17 10:07:17 +08:00
Boody 517946d778 Merge pull request #5911 from Mubelotix/patch-1
docs(readme): Fix Star History section in README
2026-08-17 02:55:27 +03:00
RaresKeYandAlexandre Teixeira 8cb8b074a4 fix(docs): map live VectorRAG result shapes (#5960)
* fix(docs): map live VectorRAG result shapes

* fix(docs): normalize optional VectorRAG fields

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-17 00:07:12 +01:00
RaresKeYandAlexandre Teixeira ee252e7cd9 fix(chat): preserve URL prefetch failures in context (#5954)
* fix(chat): preserve URL fetch failures in context

* fix(chat): avoid duplicating signed URLs in fetch failures

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-17 00:01:10 +01:00
RaresKeY 0af6a99e81 refactor(search): extract outbound fetch transport (#5953) 2026-08-16 23:43:04 +01:00
RaresKeY f562bfee01 fix(speech): define the Kokoro optional install contract (#5962) 2026-08-16 23:39:12 +01:00
RaresKeY 0728b994d8 fix: discover sessions from persisted messages (#5938)
* fix(session): discover sessions from persisted messages

Use indexed chat-row existence instead of stale derived message_count metadata during startup discovery, then repair the bounded in-memory counts so lazy hydration remains correct. Keep truly empty sessions excluded and cover stale-low and stale-high counts with real SQLite.

* test(session): isolate discovery database

* test(session): use manager database metadata
2026-08-16 23:34:27 +01:00
RaresKeY db05175e3e fix(cli): generate live task webhook URLs (#5956) 2026-08-16 23:28:26 +01:00
RaresKeY e4046aa41f fix(models): bind provider detection to DNS labels (#5961) 2026-08-16 23:25:46 +01:00
RaresKeY 71f30fcc9d fix(issues): require exact bug-report revisions (#5984) 2026-08-16 23:04:23 +01:00
LéoandAlexandre Teixeira b19d327f03 fix(auth): derive the session cookie Secure flag from the request scheme (#6048)
* fix(auth): derive the session cookie Secure flag from the request scheme

SECURE_COOKIES only marked the login cookie Secure when it was explicitly
set to true, so an HTTPS login on an install that never set it handed out a
session cookie the browser is happy to send back in cleartext.

Unset now derives the flag from the request: the connection scheme, which
uvicorn's proxy-headers middleware rewrites for the proxies it trusts, or
X-Forwarded-Proto for a terminator that is not on a trusted address. That
is the same test core/middleware.py already applies before sending HSTS, so
the two stop disagreeing about whether a request arrived over TLS. An
explicit true still forces the flag on and an explicit false turns it off
for an install still answering on both HTTP and HTTPS. Strictly more Secure
flags than before and never fewer.

Empty counts as unset, because docker-compose pinned SECURE_COOKIES=false
for every container; the compose files now pass the variable through
unset, the way FASTEMBED_CACHE_PATH already does.

The helper and its decision order come from #3799, which was closed for
being too large to review and whose six replacement PRs dropped this fix.

Part of #3803.

* docs(setup): flag the leftover SECURE_COOKIES=false on upgrades

The old default was false, so an install set up before scheme derivation
can still carry an explicit SECURE_COOKIES=false in its own .env. That
value stays authoritative, so HTTPS logins keep getting a non-Secure
session cookie even after the tracked compose defaults are updated by a
pull. Say so where people look: the security notes and the variable's
own comment in .env.example.

* docs(setup): align TLS guidance with scheme-derived cookies

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-16 22:56:36 +01:00
Léo d0bf771f9d perf(static): vendor KaTeX and Mermaid, and load them on first use (#5994)
* fix(static): vendor KaTeX and Mermaid instead of loading them from a CDN

index.html pulled katex.min.{js,css} and mermaid.min.js from cdn.jsdelivr.net on
every page load. For self-hosted software that is three problems at once: an
air-gapped or offline install renders no math and no diagrams at all, every
session announces its IP, User-Agent and Referer to a third party, and the "runs
on your own hardware" promise quietly isn't true.

static/lib/ already vendors highlight.js, docx, xlsx, mammoth, html2pdf and
qrcode, so the CDN usage was an inconsistency rather than a policy. Vendoring
also pins Mermaid, which was floating on the `11` tag, to 11.16.1.

Behaviour is unchanged: both libraries still load eagerly from <head>, just from
this machine.

- KaTeX goes in its own directory because its stylesheet resolves fonts with a
  relative url(fonts/...), so the vendored CSS needs no rewrite. Only the .woff2
  variants ship, matching static/fonts/, since a browser that supports woff2
  never requests the .woff/.ttf alternatives the stylesheet also lists.
- The service worker precaches KaTeX and its fonts so offline math is typeset
  rather than falling back to system glyphs, and CACHE_NAME is bumped. Mermaid
  is left to the existing cache-first rule: at 3.5 MB, precaching it would mean
  re-downloading it on every cache bump for a library most sessions never touch.
- Licence texts travel with the bundles in licenses/, following the convention
  the repo already uses for OpenDyslexic and DeepResearch.
- .gitattributes turns the whitespace check off for static/lib/ so `git diff
  --check` passes without stripping bytes from the published npm artifacts,
  which would desync them from upstream.

* perf(markdown): load KaTeX and Mermaid on first use, not on every page load

Both libraries loaded eagerly from <head>, costing every session ~985 KB on the
wire (929 KB of that Mermaid) even though most chats contain neither a formula
nor a diagram. Measured on a cold profile via the Resource Timing API: JS bytes
per page load drop from 3,102,141 to 2,098,634, a saving of 1,003,507 bytes, and
third-party requests per load go from 3 to 0.

markdown.js now fetches each library the first time one is actually needed:

- renderMermaid() checks for an unprocessed mermaid fence before touching the
  network, and re-queries the DOM after the load so a diagram replaced mid-stream
  still renders.
- mdToHtml() is synchronous, so when KaTeX is not in yet it banks the math source
  in an inert placeholder and schedules a flush that loads the library and swaps
  the placeholders in. Once KaTeX is loaded it typesets inline exactly as before,
  so callers that never call a render helper still get their math.

Both loaders memoise the promise rather than the module, so concurrent callers
share one fetch and a double trigger cannot start two loads; a failed load clears
the memo so the next formula retries instead of being poisoned for the session.
The flush is scheduled with setTimeout rather than requestAnimationFrame, which
is throttled to a stop in a background tab and never fires at all in a headless
browser, so math would have sat as plain source text until the tab was focused.

If neither library ever loads, math degrades to readable source text and diagrams
to their fence contents, rather than to nothing.

* fix(markdown): unescape &amp; last so math entities survive intact

The math pass unescaped &amp; before &lt; and &gt;. mdToHtml escapes the source
first, so a literal "&lt;" typed inside a formula arrives here as "&amp;lt;",
turns back into "&lt;" on the ampersand pass, and is then eaten by the very next
one. Typing $a &lt; b$ rendered as "a < b" instead of the literal text.

The code-block pass in the same function already unescapes &amp; last; only the
math paths were the outlier, in all four of the copies this branch consolidated
into pushMath(). Reordering to match makes them consistent and clears the
js/double-escaping alert CodeQL raised on this PR.

Math containing a genuinely typed "<" is unaffected, which is why this went
unnoticed for so long. Covered by a regression test asserting both cases.

* fix(markdown): decode entity-spelled math in one pass

mdToHtml escapes the source before the math pass, so a typed "<" reaches
the delimiters as "&lt;" and a typed "&lt;" reaches them as "&amp;lt;".
KaTeX has no entity syntax and reads the leftover "&" as an alignment
marker, so "$a &lt; b$" rendered as a red .katex-error instead of a
formula, on both the inline and the deferred path.

Chained replaces cannot fix it in either order: unescaping "&amp;" first
lets the next pass eat the "&lt;" it just wrote, and unescaping it last
leaves the entity spelling for KaTeX to choke on. One alternation,
longest form first, decodes every spelling and never rescans its own
output.

The tests now drive the vendored KaTeX build rather than a renderer that
echoes its input, which is why the old assertion looked correct.

* fix(document): typeset deferred math before the PDF export

exportAsPdf() renders the document into a detached container and hands
it straight to html2pdf. On a page where KaTeX has not loaded yet,
mdToHtml() returns pending placeholders and schedules a flush scoped to
document, which never reaches a node that was never attached, so the
PDF printed raw formula source.

Render the container's own math first. renderMath() returns immediately
without fetching anything when there is nothing pending, so a document
with no formulas still exports without pulling KaTeX.
2026-08-16 22:43:12 +01:00
LéoandAlexandre Teixeira 04b8829fb2 perf(frontend): share one cached fetch for /api/auth/settings and /api/tools (#5997)
* perf(frontend): share one cached fetch for settings and tools

/api/auth/settings was fetched independently by eight modules and /api/tools by
three on a single load — 4 and 3 requests measured — and any two of those
callers could observe a different snapshot of the same object. chatRenderer.js
is imported under three different ?v= query strings, so it is three separate
module instances each issuing its own /api/tools request.

appConfig.js holds one promise per endpoint, so concurrent and later callers
share it. Every writer invalidates: the settings panel routes its 16 saves
through a single helper, and the admin tools save drops both snapshots because
that route persists disabled_tools into the same settings store. A rejected
fetch clears its slot rather than being memoised, so one blip at boot cannot
leave keybinds, TTS and the search provider on defaults for the session.

The settings panel keeps reading directly: it is the writer and edits what it
reads, so it must see authoritative state.

Cold load, Resource Timing: /api/auth/settings 4 -> 1, /api/tools 3 -> 1, and
0 settings requests on the first load after a login, because the cache now
consumes the sessionStorage prefetch that login.html writes.

Fixes #5996

* fix(admin): refetch tool state when the Agent Tools panel opens

The shared cache made Admin > Tools render the boot snapshot on every
reopen. Its save posts the whole disabled list rebuilt from the checkboxes,
so a tool disabled out of band (the manage_settings tool, another tab) came
back enabled on the next unrelated toggle. Reproduced against the running
app: with api_call disabled by a separate client, toggling app_api off
posted ['app_api'] and silently re-enabled api_call.

The panel now drops the shared entry before reading it, which restores what
dev does today and keeps the startup read that chatRenderer.js shares. Cold
load is still 1 request each for /api/auth/settings and /api/tools, and the
panel costs the same 2 requests per open as dev.

* fix(static): preserve concurrent tool setting changes

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-16 21:03:05 +01:00
Léo 895bf896e3 refactor(static): load the image editor on first use (#6074)
galleryEditor.js and its js/editor/ graph are 54 modules / 576 KB, and
gallery.js imported them statically. Every page load paid for the whole
image editor even though most sessions never open the Edit tab: 54 of the
173 JS files on a cold load, and 576 KB of the decoded JS, were for a
panel that was never displayed.

Add a small panel-loader registry (static/js/panels.js) that imports a
panel's module on first use and memoises the promise, so a double-click
cannot start two loads and a failed load can still be retried. Convert
the image editor to it, and route the two existing dynamic imports in
chat.js and chatRenderer.js through the same entry so all three call
sites share one module instance instead of two.

closeEditor() and isEditorOpen() stay synchronous: if the module was
never loaded there is no edit session to close and none can be open.

The service worker keeps precaching the editor, in a separate
PANEL_PRECACHE list, so the panel stays available offline even though
index.html no longer loads it. The two lists now serve different
purposes and the header comment says so.
2026-08-16 17:54:24 +01:00
Léo cc42f38a89 fix(ci): match the screenshot checkbox by wording, not emphasis (#6073)
The PR-description check folded the template's asterisks into the pattern,
so a ticked box written without them read as unchecked while rendering
identically on the PR page. `ready for review` was silently withheld and
the bot reported missing visual evidence even with screenshots attached,
with no way to tell from the rendered PR what was wrong.

The two attestations directly above it already anchor on the wording
alone. This one now does the same, accepting `**bold**`, `*italic*`,
`__underscores__` and plain text.

Fixes #6071
2026-08-16 16:26:08 +01:00
Joeseph Grey d5514da3ab fix(tasks): scope action_tidy_research broken-file sweep to admins (#6069)
action_tidy_research took an `owner` argument and never used it. Any user's
scheduled tidy task swept data/deep_research globally, unlinking every empty
or unparseable file regardless of who owned it.

A broken file has no readable owner stamp, so it cannot be matched against
`owner` the way _find_owned_research_path does, which is why the HTTP path and
manage_research already treat parse failure as not-owned. Clearing one is a
privileged act rather than an ownership one, so gate it on the canonical
owner_is_admin_or_single_user helper: admins and the single-user operator keep
the janitor, a regular user does not, and neither does the pre-setup window
before an admin exists.

Returns before the directory glob rather than filtering inside the loop, so a
denied run reports why instead of reporting "none broken" over files it never
inspected. That reason string surfaces in Activity as a skipped row.
2026-08-16 13:19:56 +01:00
RaresKeYandAlexandre Teixeira 67e08cce1b ci(prs): separate validation readiness from description checks (#5939)
* ci(prs): separate validation readiness from description checks

* fix(ci): harden PR readiness state

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-16 13:03:06 +01:00
Léo 2e2bb5231e fix(mcp): stop assuming http://localhost:7000 for the OAuth callback (#6032)
* fix(mcp): stop assuming http://localhost:7000 for the OAuth callback

The MCP OAuth callback origin is wrong on any install not reached at
http://localhost:7000, and on Docker it cannot be corrected at all.
Three sites, one assumption:

- The redirect base fell back to a fixed port 7000. The app binds APP_PORT
  natively (app.py, launcher.py) and the macOS launcher defaults to 7860,
  where 7000 is AirPlay Receiver, so the callback lands on another service
  entirely. The fallback now follows APP_PORT. The hostname stays localhost
  rather than internal_api_base()'s 127.0.0.1: this URI is registered with
  the authorization server, so changing the host would invalidate the
  registrations that already exist.

- The paste-back form hardcoded an http:// action. Serving the page over
  HTTPS, Chrome raises its insecure-form interstitial, and overriding that
  posts plain HTTP at a TLS port, which fails too. Either way the
  authorization code never reaches Odysseus. The action now carries the
  scheme the request arrived on.

- OAUTH_REDIRECT_BASE_URL is the only fix available to a Docker install,
  because the container listens on 7000 and cannot see the host port map,
  but compose never forwarded it and nothing documented it. Both fixed.

* fix(mcp): make the paste-back form action relative and export APP_PORT

Answers the review on #6032. Three of the fixes did not survive contact with
the deployments they targeted.

- The form action derived its scheme from request.url.scheme. uvicorn only
  honours X-Forwarded-Proto from a peer inside --forwarded-allow-ips, which
  defaults to 127.0.0.1; the Dockerfile CMD sets no override, so a proxy
  arriving over the Docker bridge is untrusted and the scheme stays http.
  That is mixed content on exactly the HTTPS installs paste-back exists for.
  A relative action is resolved by the browser against the origin the page
  came from, which is right under every proxy setup, and it drops the Host
  header from the page entirely.

- The APP_PORT fallback never fired for the shipped launchers. start-macos.sh,
  the generated .app launcher and launch-windows.ps1 all pass --port to
  uvicorn without putting the value in the environment, so the motivating
  case, macOS on 7860, still registered localhost:7000. Each now exports it.
  internal_api_base() and companion pairing read APP_PORT too and were wrong
  in the same way.

- .env.example pointed Google MCP servers at OAUTH_REDIRECT_BASE_URL.
  add_server writes Desktop App credentials, and Google only accepts loopback
  redirects for that client type, so a public origin comes back as
  redirect_uri_mismatch. The variable is for the DCR flow; Google stays on the
  loopback default and finishes remotely through paste-back.

The Host header is no longer reflected into the page, so the escaping
regression test asserts its absence instead of its escaping.
2026-08-15 23:09:01 -06:00
RaresKeYandLéo f7cbc885c1 fix(docker): migrate retained SearXNG settings (#6055)
* fix(docker): migrate retained SearXNG settings

Retained nonempty SearXNG settings can miss defaults required by newer pinned images while bypassing the entrypoint's narrow regeneration checks.

Add an atomic PyYAML-aware migration to all Compose variants. Preserve existing inheritance choices, custom content, secrets, ownership, and mode while inserting only the missing top-level default-inheritance key.

Validated with 39 focused and adjacent tests, compile checks, and fresh and retained pinned-image HTTP 200 gates. Full repository CI remains for the PR.

* fix(docker): chmod the settings temp file before chowning it

The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/DAC_OVERRIDE
and carries no FOWNER, and searxng's own entrypoint chowns /etc/searxng to
searxng:searxng, so every retained settings file belongs to that user by the
second boot. Chowning the temporary file first left root unable to chmod it,
so the migration exited 1 and `set -eu` killed the container before
`exec /usr/local/searxng/entrypoint.sh` — SearXNG never started and odysseus
blocked on its healthcheck.

Swap the two calls so the chmod lands while the temporary file is still
root-owned, and cover the ordering with a test that refuses the chmod once
the chown has happened, the way the kernel does.

* fix(docker): let searxng boot when the settings migration fails

The migration runs under `set -eu`, so any settings file it cannot parse or
rewrite took the container down instead of merely going unmigrated. A symlinked
/etc/searxng/settings.yml is enough: the migration refuses a non-regular file
and searxng, which reads through the symlink perfectly well, never got to start.

Guard the call with `|| true` in all three Compose variants. The failure still
prints its reason on stderr, and searxng is left to report anything genuinely
wrong with the file.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-16 04:17:58 +02:00
Alexandre Teixeira cee319050c refactor(settings): add registry-backed navigation and finder (#6040)
* refactor(settings): add modular shell primitives

* refactor(settings): wire modular shell

* test(settings): exercise real coordinator ESM boundary

* refactor(settings): add registry-backed settings finder

* fix(settings): harden registry navigation behavior
2026-08-16 02:48:19 +01:00
RaresKeYandAlexandre Teixeira 0dd70a7556 feat(auth): define Default/Local owner contract (#5795)
* feat(auth): define default local owner contract

* test(auth): harden default local owner matrix

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-15 20:27:26 +01:00
Alexandre TeixeiraandRaresKeY 9c71948376 fix(companion): honor configured pairing address (#6060)
* fix(companion): honor configured pairing origin

* fix(companion): keep configured pairing on v1 LAN contract

* fix(companion): reject numeric pairing hosts

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-08-15 21:11:10 +02:00
RaresKeY 18991d6f67 fix(companion): preserve models with auth disabled (#5797)
* fix(companion): preserve models with auth disabled

* test(companion): guard auth-disabled model scoping
2026-08-15 19:47:51 +01:00
Joeseph Grey 79b891c7ee Merge pull request #5817 from RaresKeY/fix/agent-external-context-gate
fix(agent): gate tools after external context
2026-08-15 12:26:00 -06:00
Léo 60bed54703 fix(chat): centre the agent-thread terminating dot on the rail (#6059)
The timeline's terminating dot used a single left offset (-17px) at both
breakpoints, but the thread's padding-left differs (22px desktop, 18px
mobile) and the step dots already carry a per-breakpoint offset. The 6px
dot therefore landed 2px right of the 2px rail on desktop and 2px left of
it on mobile, which is the visible kink under an expanded last step.

Derive each offset from the rail's centre instead: the rail sits at
left:5px and is 2px wide, so the dot's left edge belongs at 3px, giving
3px - padding-left per breakpoint.
2026-08-15 19:24:19 +01:00
RaresKeY 443f7d2963 fix(auth): normalize mounted request paths (#5807)
* fix(auth): normalize mounted request paths

* fix: make login page mount-aware
2026-08-15 18:55:15 +01:00
RaresKeY d401e806d4 fix(agent): retire superseded approvals 2026-08-15 07:49:52 +00:00
RaresKeY 105a7c0d96 fix(agent): close exact approval edge cases 2026-08-15 07:44:32 +00:00
RaresKeY 73a4b10642 fix(agent): approve teacher-generated skills 2026-08-15 07:26:51 +00:00
RaresKeY 94cf119b11 fix(agent): taint model-visible tool responses 2026-08-15 07:18:25 +00:00
RaresKeY 7a138e8a3f fix(agent): seal document approval content 2026-08-15 07:01:36 +00:00
RaresKeY 2b72531eaa fix(agent): harden approval lifecycle 2026-08-15 06:52:44 +00:00
RaresKeY 58b2a4bfa9 fix(agent): close approval continuation gaps 2026-08-15 06:14:37 +00:00
RaresKeY fd50561af6 fix(ui): complete exact approval continuation 2026-08-15 05:51:54 +00:00
RaresKeY 1b09c568d8 fix(agent): authorize exact actions after untrusted context 2026-08-15 05:37:47 +00:00
RaresKeY 2811c7e815 fix(agent): keep ambient context fail closed 2026-08-15 04:18:05 +00:00
RaresKeY 1f216cfd0e fix(agent): taint stored document tool results 2026-08-15 04:13:56 +00:00
RaresKeY b715b81ad0 fix(agent): preserve authorized document event order 2026-08-15 04:03:46 +00:00
RaresKeY 05442a9945 fix(agent): close external-context gate gaps 2026-08-15 03:52:23 +00:00
RaresKeY 2295504141 fix(agent): close untrusted-context gate bypasses 2026-08-15 01:58:32 +00:00
RaresKeY 329f9d298d fix: taint prefetched web context 2026-08-15 01:57:09 +00:00
RaresKeY fef0e6f3c0 fix(agent): gate tools after external context
Classify built-in tool effects in a server-owned registry and carry run-local external-context integrity state through the agent loop and dispatcher. Block high-impact and unknown actions after successful external results, including same-batch calls, without relying on model compliance.
2026-08-15 01:57:08 +00:00
Mubelotix 45fc3938e0 Fixes Star History section in README
Fixes part of #5563
2026-08-06 19:18:56 +02:00
194 changed files with 20753 additions and 2603 deletions
+29 -2
View File
@@ -76,12 +76,24 @@ SEARXNG_INSTANCE=http://localhost:8080
# Change this if another local service already uses 7000 (macOS AirPlay often does).
# APP_PORT=7000
# Optional HTTP address advertised in companion/mobile pairing codes. Set this
# when Docker would otherwise advertise a container address or loopback. Use a
# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local
# name that the phone can reach. HTTPS and public hostnames are not supported
# by the current companion client. Do not include credentials, a path, query,
# or fragment.
# COMPANION_BASE_URL=http://192.168.1.50:7000
# Development-only auth bypass for loopback requests.
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
# LOCALHOST_BYPASS=false
# Mark session cookies Secure. Set true when Odysseus is served through HTTPS
# by a trusted reverse proxy or private access gateway.
# Mark session cookies Secure. Left unset, this follows the request scheme:
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
# force it on, or false to force it off while you still serve plain HTTP.
# Upgrading: this used to default to false. Drop a leftover SECURE_COOKIES=false
# from your .env unless you still need that escape hatch — it keeps HTTPS logins
# on a non-Secure cookie.
# SECURE_COOKIES=true
# Optional: pre-seed the first admin password during setup.
@@ -151,6 +163,21 @@ SEARXNG_INSTANCE=http://localhost:8080
# Local HTTP setups may use the callback URL inferred by the application.
# GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback
# Origin the MCP OAuth callback is sent back to, for remote (Streamable HTTP)
# MCP servers that register it dynamically. Defaults to http://localhost:$APP_PORT,
# which is right only when you reach Odysseus directly on that port. Set it for
# HTTPS, reverse-proxy, hosted, and Docker installs — inside the container the
# app always listens on 7000 and cannot see the host port map, so the default is
# wrong there whenever APP_PORT is not 7000.
#
# Not for Google MCP servers. Those use Desktop App credentials, and Google only
# accepts loopback redirect URIs for that client type, so a public origin here is
# rejected with redirect_uri_mismatch. Leave it unset for a Google-only install:
# the loopback default is what Google wants, and remote users finish through the
# paste-back page, which never has to load the redirect.
# https://developers.google.com/identity/protocols/oauth2/native-app
# OAUTH_REDIRECT_BASE_URL=https://your-domain.com
# ============================================================
# Misc
# ============================================================
+7
View File
@@ -15,6 +15,13 @@ docker/entrypoint.sh text eol=lf
*.cmd text eol=crlf
*.bat text eol=crlf
# Vendored third-party bundles in static/lib/ are published minified artifacts
# and must stay byte-identical to what npm ships — stripping trailing whitespace
# to satisfy `git diff --check` would desync them from the upstream release. Turn
# the whitespace check off for that tree instead, and keep the bundles out of
# GitHub's language statistics.
static/lib/** -whitespace linguist-vendored
# Binary assets — never normalize.
*.png binary
*.jpg binary
+12
View File
@@ -26,6 +26,18 @@ body:
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
required: true
- type: input
id: revision
attributes:
label: Odysseus Revision
description: |
From the repository root (on the host when using Docker), run
`git show -s --abbrev=12 --format='%h (%cs)' HEAD`
and paste the output exactly.
placeholder: "1fef4929cf1d (2026-08-11)"
validations:
required: true
- type: dropdown
id: install-method
attributes:
+1
View File
@@ -28,6 +28,7 @@ Fixes #
- [ ] This PR targets `dev`
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
- [ ] I did not run the app/runtime validation and stated that gap in **How to Test**. Leave this unchecked when the app-run box above is checked.
## How to Test
@@ -41,6 +41,14 @@ module.exports = async ({ github, context, core }) => {
break;
case 'bug': {
const revisionText = section('Odysseus Revision');
if (!/^[0-9a-f]{12} \(\d{4}-\d{2}-\d{2}\)$/i.test(revisionText)) {
failures.push(
'**Odysseus Revision** — paste the 12-character commit SHA and date, ' +
'for example `1fef4929cf1d (2026-08-11)`',
);
}
if (!section('Install Method')) {
failures.push('**Install Method** — select how you installed Odysseus');
}
+142 -32
View File
@@ -21,11 +21,11 @@ module.exports = async ({ github, context, core }) => {
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
}
const problems = [];
const descriptionProblems = [];
// 1. Summary must be filled in.
if (section('Summary').length < 20) {
problems.push('**Summary** is empty or too short — describe what changed and why.');
descriptionProblems.push('**Summary** is empty or too short — describe what changed and why.');
}
// 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing
@@ -34,18 +34,18 @@ module.exports = async ({ github, context, core }) => {
const linkedSection = section('Linked Issue');
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
if (!linkedSection || !hasIssueRef) {
problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
descriptionProblems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
}
// 3. At least one Type of Change box must be checked.
const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? '';
if (!/- \[x\]/i.test(typeBlock)) {
problems.push('**Type of Change** — check at least one box.');
descriptionProblems.push('**Type of Change** — check at least one box.');
}
// 4. Duplicate-search checklist item must be checked.
if (!/- \[x\] I searched/i.test(body)) {
problems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
descriptionProblems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
}
// 5. How to Test must contain enough real detail for a reviewer to act on.
@@ -53,7 +53,83 @@ module.exports = async ({ github, context, core }) => {
// code block — so we only require non-trivial content, not a specific shape.
const howTo = section('How to Test');
if (howTo.length < 30) {
problems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
descriptionProblems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
}
// Classify paths from GitHub's API. This workflow runs in the privileged base
// context, so it must never check out or execute code from the PR branch.
const changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: prNum, per_page: 100,
});
const changedPaths = changedFiles.map(file => file.filename);
function isUiSensitivePath(filename) {
const path = filename.toLowerCase();
return path.startsWith('static/')
|| path.startsWith('templates/')
|| /\.(?:html?|css|svg)$/.test(path);
}
function isDocsOnlyPath(filename) {
const path = filename.toLowerCase();
return /\.(?:md|mdx|rst|adoc|txt)$/.test(path)
|| (path.startsWith('docs/') && !isUiSensitivePath(path));
}
function isRuntimeSensitivePath(filename) {
const path = filename.toLowerCase();
if (isUiSensitivePath(path)) return false;
if (path.startsWith('tests/') || path.startsWith('.github/')) return false;
return /^(?:app\.py|routes\/|services\/|src\/|core\/|mcp_servers\/|scripts\/|docker\/)/.test(path)
|| /^(?:dockerfile|docker-compose.*\.ya?ml|requirements(?:-optional)?\.txt|pyproject\.toml|setup\.py)$/.test(path)
|| /\.(?:py|sh|ps1|bat)$/.test(path);
}
let classification = 'tooling';
if (changedPaths.some(isUiSensitivePath)) {
classification = 'UI-sensitive';
} else if (changedPaths.some(isRuntimeSensitivePath)) {
classification = 'backend/runtime';
} else if (changedPaths.length > 0 && changedPaths.every(isDocsOnlyPath)) {
classification = 'docs-only';
}
const appRan = /- \[x\]\s+I actually ran the app\b/i.test(body);
const appNotRun = /- \[x\]\s+I did not run the app\/runtime validation\b/i.test(body);
// Anchor on the wording, not the template's emphasis: a ticked box the author
// retyped without the surrounding ** renders identically on the PR page, so
// treating it as unchecked is invisible from their side. Matches the two
// attestations above, which already ignore formatting.
const screenshotChecked = /- \[x\]\s+[*_]{0,2}Screenshot or short clip[*_]{0,2}/i.test(body);
const screenshotSection = section('Screenshots / clips');
const hasVisualEvidence = /!\[[^\]]*\]\([^)]+\)|<(?:img|video|source)\b[^>]*(?:src|href)=|https?:\/\/[^\s)]+/i.test(screenshotSection);
const evidenceGaps = [];
let needsRuntimeValidation = false;
let needsVisualEvidence = false;
if (classification === 'backend/runtime' || classification === 'UI-sensitive') {
if (appRan && appNotRun) {
needsRuntimeValidation = true;
evidenceGaps.push('The app-run and explicit not-run boxes are both checked. Select the one state that is true.');
} else if (!appRan) {
needsRuntimeValidation = true;
if (appNotRun) {
evidenceGaps.push('The author explicitly reports that app/runtime validation was not performed.');
} else {
evidenceGaps.push('App/runtime validation is not author-attested. Check the run box only after running it, or check the explicit not-run box and describe the gap.');
}
}
}
if (classification === 'UI-sensitive') {
if (!screenshotChecked) {
needsVisualEvidence = true;
evidenceGaps.push('The screenshot/clip checkbox is not checked for this UI-sensitive change.');
}
if (!hasVisualEvidence) {
needsVisualEvidence = true;
evidenceGaps.push('The Screenshots / clips section does not contain an actual attachment or link.');
}
}
// ── Comment ──────────────────────────────────────────────────────────────
@@ -62,22 +138,43 @@ module.exports = async ({ github, context, core }) => {
});
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
if (problems.length === 0) {
if (descriptionProblems.length === 0 && evidenceGaps.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
}
} else {
const commentBody = [
MARKER,
'⚠️ **PR description — action needed**',
'',
'The following required sections are missing or incomplete. Please update the PR description to address them:',
'',
problems.map(p => `- ${p}`).join('\n'),
const commentLines = [MARKER];
if (descriptionProblems.length > 0) {
commentLines.push(
'⚠️ **PR description — action needed**',
'',
'The following required sections are missing or incomplete. Please update the PR description to address them:',
'',
descriptionProblems.map(problem => `- ${problem}`).join('\n'),
);
} else {
commentLines.push(
'⚠️ **PR description is complete; validation evidence is still outstanding**',
'',
`Changed-file classification: **${classification}**.`,
);
}
if (evidenceGaps.length > 0) {
commentLines.push(
'',
'**Author-reported runtime / visual state**',
'',
evidenceGaps.map(gap => `- ${gap}`).join('\n'),
'',
'Checkboxes are author attestations. GitHub Actions results remain the execution evidence for CI; this check does not prove that a local command ran.',
);
}
commentLines.push(
'',
'---',
'_This comment is deleted automatically once all sections are complete._',
].join('\n');
'_This comment updates automatically when the description or changed files change._',
);
const commentBody = commentLines.join('\n');
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
@@ -97,34 +194,47 @@ module.exports = async ({ github, context, core }) => {
return true;
} catch (e) {
if (e.status === 404) return false;
if (e.status === 403) {
core.warning(`Could not inspect label "${name}" — token lacks label read access; skipping.`);
return false;
}
throw e;
}
}
async function swapLabel(num, add, remove) {
if (await labelExists(add)) {
async function setLabel(name, wanted) {
if (wanted && await labelExists(name)) {
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] });
await github.rest.issues.addLabels({ owner, repo, issue_number: prNum, labels: [name] });
} catch (e) {
// Fail soft on a token that can't write labels so a label permission
// problem never masks the actual description verdict.
if (e.status !== 403) throw e;
core.warning(`Could not add "${add}" — token lacks label write here; skipping.`);
if (e.status !== 403 && e.status !== 404) throw e;
core.warning(`Could not add "${name}" — label is unavailable or the token lacks label write access; skipping.`);
}
} else if (wanted) {
core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`);
} else {
core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`);
}
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove });
} catch (e) {
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNum, name });
} catch (e) {
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
}
}
}
if (problems.length === 0) {
await swapLabel(prNum, 'ready for review', 'needs work');
} else {
await swapLabel(prNum, 'needs work', 'ready for review');
core.setFailed(`PR description has ${problems.length} issue(s) — see bot comment for details.`);
const descriptionComplete = descriptionProblems.length === 0;
const evidenceComplete = evidenceGaps.length === 0;
const isDraft = Boolean(context.payload.pull_request.draft);
await setLabel(
'ready for review',
descriptionComplete && evidenceComplete && !isDraft,
);
await setLabel('needs work', !descriptionComplete);
await setLabel('needs runtime validation', needsRuntimeValidation);
await setLabel('needs visual evidence', needsVisualEvidence);
if (!descriptionComplete) {
core.setFailed(`PR description has ${descriptionProblems.length} issue(s) — see bot comment for details.`);
}
};
+9 -3
View File
@@ -5,7 +5,11 @@ on:
# works on fork PRs. Safe here: the checkout pins to the base branch (no fork
# code runs) and the scripts only read context.payload and call the GitHub API.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, synchronize, reopened, ready_for_review]
types: [opened, edited, synchronize, reopened, ready_for_review, converted_to_draft]
concurrency:
group: pr-description-${{ github.event.pull_request.number }}
cancel-in-progress: true
# Default-deny at the workflow level; each job opts into only the scopes it needs.
# Note: modifying a PR's labels/comments needs pull-requests:write even though the
@@ -59,12 +63,14 @@ jobs:
check-mergeable:
name: Flag unmergeable PRs
needs: check-description
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
# Skip bots: they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot'
# Run after description validation failures, but never from an obsolete
# workflow run canceled by a newer PR event.
if: ${{ !cancelled() && github.event.pull_request.user.type != 'Bot' }}
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+10 -2
View File
@@ -65,6 +65,16 @@ Vendored in `static/lib/` and served directly:
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
KaTeX and Mermaid are loaded on first use by `static/js/markdown.js` rather than
from `index.html`, so a session that renders no math and no diagram never fetches
either. Only the `.woff2` KaTeX fonts are shipped, matching `static/fonts/`; the
`.woff` and `.ttf` variants its stylesheet also lists are never requested by a
browser that supports `woff2`. The bundles are the published npm artifacts,
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
so they can stay byte-identical to upstream.
## Front-end libraries loaded at runtime (CDN)
@@ -72,8 +82,6 @@ Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not v
| Library | Purpose | License |
|---|---|---|
| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT |
| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT |
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
+10 -5
View File
@@ -59,15 +59,20 @@ Help is welcome. The best entry points are fresh-install testing, provider setup
## Security
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. 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
<a href="https://www.star-history.com/?repos=odysseus-dev%2Fodysseus&type=date&legend=top-left">
<a href="https://star-history.dera.page/#odysseus-dev/odysseus&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
</picture>
</a>
+1 -1
View File
@@ -10,7 +10,7 @@ Security fixes are handled on the default branch until formal releases are cut.
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
- Set `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Set `SECURE_COOKIES=true` to force it on (for a proxy Odysseus cannot see the scheme of), or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
- Use HTTPS when exposing the app beyond localhost.
- Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN.
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only.
+1 -1
View File
@@ -37,7 +37,7 @@ Non-admin defaults are in `core/auth.py:DEFAULT_PRIVILEGES`. Tool enforcement is
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
- **Reserved usernames:** `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.
- **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.
+20 -7
View File
@@ -67,7 +67,13 @@ from core.constants import (
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
)
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.exceptions import (
SessionNotFoundError, InvalidFileUploadError,
@@ -78,6 +84,7 @@ import bcrypt as _bcrypt
from src.app_helpers import abs_join, serve_html_with_nonce
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
from src.owner_identity import auth_disabled
from starlette.responses import RedirectResponse
# ========= LOGGING =========
@@ -248,7 +255,7 @@ from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
auth_manager = AuthManager()
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"
if LOCALHOST_BYPASS:
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:
if path in AUTH_EXEMPT_EXACT:
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 any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
@@ -355,7 +362,7 @@ if AUTH_ENABLED:
class AuthMiddleware(BaseHTTPMiddleware):
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)
# carries no credentials by design and must reach CORSMiddleware to be
# answered. AuthMiddleware is the outermost middleware, so gating the
@@ -399,7 +406,10 @@ if AUTH_ENABLED:
if not auth_manager.is_configured:
# No users yet — redirect to login for first-time setup
if not path.startswith("/api/"):
return RedirectResponse(url="/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"})
# --- Bearer token auth (API tokens for external integrations) ---
@@ -461,7 +471,10 @@ if AUTH_ENABLED:
if not auth_manager.validate_token(token):
if path.startswith("/api/"):
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
return RedirectResponse(url="/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
request.state.current_user = auth_manager.get_username_for_token(token)
@@ -771,7 +784,7 @@ from src.task_scheduler import TaskScheduler
task_scheduler = TaskScheduler(session_manager)
from src.event_bus import set_task_scheduler
set_task_scheduler(task_scheduler)
from routes.task_routes import setup_task_routes
from routes.task.task_routes import setup_task_routes
app.include_router(setup_task_routes(task_scheduler))
from routes.assistant_routes import setup_assistant_routes
+4
View File
@@ -73,6 +73,10 @@ cat > "$APP/Contents/MacOS/$APP_NAME.tmpl" <<'LAUNCHER'
INSTALL_DIR="__INSTALL_DIR__"
PORT="__PORT__"
URL="http://127.0.0.1:${PORT}"
# uvicorn is started with --port below, but APP_PORT is what the app itself
# reads when it needs to build a URL for this instance (internal_api_base(),
# companion pairing, the MCP OAuth callback), so export it as well.
export APP_PORT="$PORT"
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
UVICORN="$INSTALL_DIR/venv/bin/uvicorn"
+99
View File
@@ -6,11 +6,14 @@ units so the route layer stays thin and the logic is directly testable.
from __future__ import annotations
import ipaddress
import json
import os
import re
import secrets
import socket
import uuid
from urllib.parse import urlsplit
import bcrypt
@@ -20,6 +23,102 @@ PAIRING_VERSION = 1
COMPANION_SCOPE = "chat"
_COMPANION_IPV4_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.168.0.0/16",
)
)
_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
def _valid_companion_client_host(host: str) -> bool:
"""Match the host forms supported by the current v1 Expo client."""
if not host or len(host) > 253 or not host.isascii() or "%" in host:
return False
try:
address = ipaddress.ip_address(host)
except ValueError:
labels = host.split(".")
if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels):
return False
if any(label.startswith("xn--") for label in labels):
return False
# WHATWG URL parsers treat a decimal or ``0x`` single-label hostname
# as an IPv4 number even though Python's strict ``ipaddress`` parser
# rejects that spelling. The v1 client interpolates this host back
# into a URL, so accepting e.g. ``134744072`` would make the phone send
# its bearer token to public 8.8.8.8. Keep DNS labels unambiguous.
if len(labels) == 1 and (
labels[0].isdigit()
or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None
):
return False
return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local")
return isinstance(address, ipaddress.IPv4Address) and any(
address in network for network in _COMPANION_IPV4_NETWORKS
)
def parse_companion_base_url(value: str) -> tuple[str, int]:
"""Validate a v1 companion address and return its legacy (host, port).
The deployed client understands only HTTP plus a LAN-style host and port.
Reject anything outside that exact contract instead of advertising a URL
the client would reject, downgrade, or interpret differently.
"""
if not isinstance(value, str) or not value:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
if not value.isascii():
raise ValueError("COMPANION_BASE_URL must contain only ASCII characters")
if any(
ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"}
for char in value
):
raise ValueError(
"COMPANION_BASE_URL contains a forbidden character"
)
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as exc:
raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc
host = parsed.hostname
if parsed.scheme.lower() != "http" or not parsed.netloc or not host:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
if parsed.username is not None or parsed.password is not None:
raise ValueError("COMPANION_BASE_URL must not contain credentials")
if parsed.path or parsed.query or parsed.fragment:
raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment")
if port is not None and not 1 <= port <= 65535:
raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535")
if not _valid_companion_client_host(host):
raise ValueError("COMPANION_BASE_URL host is not supported by companion v1")
netloc = f"{host}:{port}" if port is not None else host
origin = f"http://{netloc}"
if value != origin:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
return host, port or 80
def configured_companion_origin() -> tuple[str, int] | None:
"""Return the validated operator-configured v1 address, if any."""
value = os.environ.get("COMPANION_BASE_URL")
if value is None or value == "":
return None
return parse_companion_base_url(value)
def default_port() -> int:
"""Best guess at the port the server is reachable on. Callers that know the
real request port should pass it explicitly."""
+23 -8
View File
@@ -23,7 +23,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from core.middleware import require_admin
from src.auth_helpers import get_current_user
from src.auth_helpers import _auth_disabled, get_current_user
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
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
scope to the token's real owner instead, plus legacy null-owner shared
rows -- the same rule as owner_filter. Read-only; never returns api_key
material.
rows -- the same rule as owner_filter. Explicit auth-disabled mode keeps
the stock route's single-user all-endpoints view. Read-only; never
returns api_key material.
"""
require_models_scope(request)
import json as _json
@@ -123,6 +124,11 @@ def setup_companion_routes() -> APIRouter:
from src.endpoint_resolver import build_chat_url
owner = token_owner(request)
single_user_mode = (
owner is None
and not getattr(request.state, "api_token", False)
and _auth_disabled()
)
out = []
db = SessionLocal()
try:
@@ -133,7 +139,7 @@ def setup_companion_routes() -> APIRouter:
if owner:
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
for ep in q.all():
if not owner_can_see(ep.owner, owner):
if not single_user_mode and not owner_can_see(ep.owner, owner):
continue
try:
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
payload for an in-app pairing screen."""
require_admin(request)
try:
configured_origin = _pairing.configured_companion_origin()
except ValueError as exc:
raise HTTPException(500, str(exc)) from None
owner = get_current_user(request)
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
token_id, raw_token = mint_pairing_token(owner, invalidate)
hosts = _pairing.lan_ip_candidates()
host = hosts[0] if hosts else "127.0.0.1"
port = request.url.port or _pairing.default_port()
if configured_origin:
host, port = configured_origin
hosts = [host]
else:
hosts = _pairing.lan_ip_candidates()
host = hosts[0] if hosts else "127.0.0.1"
port = request.url.port or _pairing.default_port()
payload = _pairing.pairing_payload(host, port, raw_token)
qr = _pairing.pairing_qr_png_data_uri(payload)
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
if (request.query_params.get("format") or "").lower() == "json":
return {
response = {
"host": host,
"port": port,
"token": raw_token,
@@ -215,6 +229,7 @@ def setup_companion_routes() -> APIRouter:
"payload": payload,
"qr": qr if qr_ok else None,
}
return response
import json as _json
payload_json = _json.dumps(payload, separators=(",", ":"))
+28 -10
View File
@@ -30,11 +30,20 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
finally:
# Directly unlink to avoid a check-then-act race condition.
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
try:
os.unlink(tmp)
except OSError:
pass
def atomic_write_text(path: str, text: str) -> None:
@@ -42,8 +51,17 @@ def atomic_write_text(path: str, text: str) -> None:
raise TypeError("atomic_write_text expects a string")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
finally:
# Directly unlink to avoid a check-then-act race condition.
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
try:
os.unlink(tmp)
except OSError:
pass
+9 -16
View File
@@ -20,7 +20,6 @@ logger = logging.getLogger(__name__)
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
from core.middleware import INTERNAL_TOOL_USER # noqa: E402
DEFAULT_PRIVILEGES = {
"can_use_agent": True,
@@ -49,24 +48,18 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
ADMIN_PRIVILEGES["block_all_models"] = False
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
from src.owner_identity import RESERVED_AUTH_USERNAMES
DEFAULT_AUTH_PATH = AUTH_FILE
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
# sentinels; they must never belong to a real account. The most dangerous is
# "internal-tool": `core.middleware.require_admin` treats any request whose
# `current_user == "internal-tool"` as the in-process tool loopback and grants
# admin, and because the cookie auth path sets `current_user` to the raw
# username, an account literally named "internal-tool" would be silently
# treated as an admin by every `require_admin`-gated route. "api" collides with
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
# synthetic-owner set the rest of the codebase already special-cases (see
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
# of those names would be denied an assistant and inconsistently owner-scoped.
# Refuse to create or rename into any of them so the sentinels can't be
# impersonated. (Keep this in sync with that synthetic-owner set.)
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
# Usernames the auth + middleware layer reserves for request sentinels and
# internal storage owners; they must never belong to a real login account.
# "internal-tool" is the most dangerous because `core.middleware.require_admin`
# treats it as the in-process tool loopback. "api" collides with bearer-token
# attribution. "demo"/"system" are synthetic owners already special-cased by
# scheduler/assistant/research paths. The Default/Local owner is a storage
# bucket for explicit auth-disabled no-login mode, not a login username.
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
+29 -3
View File
@@ -3,10 +3,14 @@
import os
import secrets
from collections.abc import Mapping
from fastapi import HTTPException, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from starlette.routing import get_route_path
from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
# Per-process token that lets the in-app tool layer hit admin-gated
@@ -15,8 +19,30 @@ from starlette.responses import Response
# same value from this module. Never persisted or exposed externally.
INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
# 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:
@@ -47,7 +73,7 @@ def require_admin(request: Request):
pass
auth_mgr = getattr(request.app.state, "auth_manager", None)
if os.getenv("AUTH_ENABLED", "true").lower() == "false":
if auth_disabled():
return
if not auth_mgr or not auth_mgr.is_configured:
raise HTTPException(403, "Admin only")
+51 -1
View File
@@ -8,6 +8,11 @@ These are simple datacontainers. All persistence is handled by SessionManager.
from dataclasses import dataclass
from typing import Dict, List, Any, Optional, TYPE_CHECKING
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
CHAT_SESSION_APPROVAL_DECISION,
)
if TYPE_CHECKING:
from .session_manager import SessionManager
@@ -31,6 +36,35 @@ set_session_manager = set_session_manager_instance
get_session_manager = get_session_manager_instance
def _history_grants_chat_session_approval(
history: List["ChatMessage"],
session_id: str,
) -> bool:
"""Return whether this exact chat has a resolved session-scope grant."""
expected_session = str(session_id or "")
if not expected_session:
return False
for message in reversed(history or []):
metadata = getattr(message, "metadata", None)
if not isinstance(metadata, dict):
continue
tool_events = metadata.get("tool_events")
if not isinstance(tool_events, list):
continue
for event in reversed(tool_events):
ask_user = event.get("ask_user") if isinstance(event, dict) else None
if not isinstance(ask_user, dict):
continue
if (
ask_user.get("kind") == "tool_approval"
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
and str(ask_user.get("session_id") or "") == expected_session
):
return True
return False
@dataclass
class ChatMessage:
"""A single chat message."""
@@ -116,11 +150,27 @@ class Session:
the model. Display/history-load paths use the raw ``history`` and are
unaffected.
"""
return [
messages = [
msg.to_dict()
for msg in self.history
if (msg.metadata or {}).get("source") != "slash"
]
if not _history_grants_chat_session_approval(self.history, self.id):
return messages
# Keep the grant close to the latest user request so route-neutral
# compaction/trimming preserves it. Copy the metadata instead of
# mutating the durable transcript object.
for index in range(len(messages) - 1, -1, -1):
if messages[index].get("role") != "user":
continue
message = dict(messages[index])
metadata = dict(message.get("metadata") or {})
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
message["metadata"] = metadata
messages[index] = message
break
return messages
def get(self, key: str, default=None):
"""Dict-like access for compatibility."""
+17 -1
View File
@@ -14,6 +14,8 @@ import logging
from datetime import datetime, timezone, timedelta
from typing import Dict, Optional
from sqlalchemy import func
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
from .models import Session, ChatMessage
from src.attachment_refs import persistable_message_content
@@ -92,14 +94,28 @@ class SessionManager:
try:
db_sessions = db.query(DbSession).filter(
DbSession.archived == False,
DbSession.message_count > 0,
DbSession.messages.any(),
).order_by(DbSession.last_accessed.desc()).limit(100).all()
# message_count is derived metadata and can drift after interrupted
# or legacy writes. Count only the bounded discovery set so startup
# remains metadata-only while lazy hydration sees an authoritative
# positive count for every discovered non-empty session.
message_counts = {}
if db_sessions:
message_counts = dict(
db.query(DbChatMessage.session_id, func.count(DbChatMessage.id))
.filter(DbChatMessage.session_id.in_([row.id for row in db_sessions]))
.group_by(DbChatMessage.session_id)
.all()
)
loaded_count = 0
for db_session in db_sessions:
try:
session = self._db_to_session_meta(db_session)
if session is not None:
session.message_count = message_counts[db_session.id]
self.sessions[db_session.id] = session
loaded_count += 1
except Exception as e:
+12 -1
View File
@@ -46,10 +46,11 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
@@ -74,6 +75,11 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
@@ -129,12 +135,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+12 -1
View File
@@ -45,10 +45,11 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
@@ -73,6 +74,11 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
@@ -132,12 +138,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+12 -1
View File
@@ -34,10 +34,11 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
@@ -62,6 +63,11 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before
@@ -110,12 +116,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+30 -8
View File
@@ -441,10 +441,19 @@ A grab-bag of small gotchas that otherwise turn into long debugging sessions.
| Package | Feature unlocked |
|---------|-----------------|
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
| `kokoro`, `soundfile` | Local Kokoro-82M text-to-speech on a CUDA GPU. The pinned Kokoro release supports Odysseus installs on Python 3.11-3.12; these packages are intentionally skipped on Python 3.13+ (including the Python 3.14 container image). |
| `ddgs` | DuckDuckGo as a search provider option. |
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
Install the optional set only when you need these features:
```bash
pip install -r requirements-optional.txt
```
The default Docker image currently uses Python 3.14, while Kokoro 0.9.4 declares Python `>=3.10,<3.13`. Odysseus itself continues to support Python 3.11+, but this pinned optional local-TTS feature requires a native Python 3.11 or 3.12 environment. Kokoro declares `torch`, but the local provider only activates when that torch build has CUDA and a GPU is visible; install the CUDA build appropriate for your host. Browser and configured endpoint TTS remain available on Python 3.13+ and in the container image.
### Faster, reproducible installs with uv (optional)
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
@@ -475,7 +484,7 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Use `SECURE_COOKIES=true` to force it on for a proxy whose scheme Odysseus cannot see, or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
@@ -486,6 +495,14 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
> **Upgrading an existing install:** `SECURE_COOKIES` used to default to
> `false`, so an install set up before scheme derivation may still carry
> `SECURE_COOKIES=false` in its own `.env`. That explicit value stays
> authoritative, so HTTPS logins keep getting a non-`Secure` session cookie.
> Pulling this change updates the tracked Compose files, but nothing rewrites
> your `.env` — drop the line from it unless you deliberately serve plain HTTP
> alongside HTTPS and want the escape hatch.
### Private or proxied deployments
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
@@ -494,7 +511,7 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
4. Keep raw service and model ports internal-only.
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true` and `LOCALHOST_BYPASS=false`. Any proxy that forwards `X-Forwarded-Proto: https` gets `Secure` session cookies without configuration, so `SECURE_COOKIES` only needs setting when you want to override that — force it on for a proxy that forwards no scheme at all, or off while you still serve plain HTTP.
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
#### Faster over the network: HTTP/2
@@ -582,9 +599,12 @@ Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
run the proxy as another container, or on the host pointing at the published
port.
**4. Point Odysseus at the new origin** in `.env`, then restart it:
**4. Point Odysseus at the new origin** in `.env`, then restart it.
A proxy that exposes the HTTPS request scheme to Odysseus needs no `SECURE_COOKIES` setting. Only force it on when the proxy cannot expose that scheme:
```bash
# only if the proxy cannot expose the external HTTPS scheme to Odysseus:
SECURE_COOKIES=true
# only if you use remote MCP servers with OAuth:
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
@@ -619,10 +639,12 @@ enable it by right-clicking the column headers.
Three things bite when moving an existing install behind TLS:
- Set `SECURE_COOKIES=true` **at the same time** you stop serving plain HTTP,
not before. The flag is applied to every login regardless of the scheme the
request arrived on, so while an HTTP entrypoint is still reachable the
browser will reject the `Secure` cookie there and login will appear to loop.
- Leave `SECURE_COOKIES` unset when Odysseus can see the external HTTPS scheme;
the cookie then follows the request automatically. If your proxy cannot expose
that scheme, set `SECURE_COOKIES=true` **at the same time** you stop serving
plain HTTP, not before. An explicit `true` applies to every login, so while an
HTTP entrypoint is still reachable the browser will reject the `Secure` cookie
there and login will appear to loop.
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
Gmail redirect URI it cannot be derived from a request — it is registered
with each MCP authorization server up front — so set it to the external
@@ -675,7 +697,7 @@ Key settings:
| `AUTH_ENABLED` | `true` | Enable/disable login |
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
| `SECURE_COOKIES` | derived from the request scheme | Marks session cookies `Secure` on HTTPS requests. Set true to force it on, false to force it off. |
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
+4
View File
@@ -163,6 +163,10 @@ if (Test-Path $cudaBase) {
}
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
# -Port only reaches uvicorn as a flag. Everything that builds a URL for this
# instance - internal_api_base(), companion pairing, the MCP OAuth callback -
# reads APP_PORT, so set it too or they all assume 7000.
$env:APP_PORT = $Port
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
Write-Host "Press Ctrl+C to stop."
Write-Host ""
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -12,6 +12,16 @@
# GPU-accelerated transcription — it's auto-detected, CPU is used otherwise.
faster-whisper
# Local text-to-speech via Kokoro-82M for the "local" TTS provider.
# Kokoro 0.9.4 declares Python >=3.10,<3.13; Odysseus itself requires 3.11+,
# so pip installs these extras on 3.11-3.12 and deliberately skips them on
# Python 3.13+ (including the Python 3.14 container image). Kokoro declares
# torch; the local provider still
# requires a CUDA-enabled torch build and GPU at runtime. SoundFile is separate
# in Kokoro's official install instructions and is not a transitive dependency.
kokoro==0.9.4; python_version >= "3.11" and python_version < "3.13"
soundfile; python_version >= "3.11" and python_version < "3.13"
# DuckDuckGo as a search provider option.
# Install if you want DDG in the search-provider dropdown.
# Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE.
+4 -3
View File
@@ -16,7 +16,7 @@ from pydantic import BaseModel
from core.database import SessionLocal, CrewMember, ScheduledTask
from src.auth_helpers import get_current_user
from core.auth import RESERVED_USERNAMES
from src.owner_identity import REQUEST_SENTINEL_OWNERS
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
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
# 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:
"""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}")
db = SessionLocal()
try:
+28 -1
View File
@@ -86,6 +86,33 @@ class SetOpenRegistrationRequest(BaseModel):
SESSION_COOKIE = "odysseus_session"
def _secure_cookie(request: Request) -> bool:
"""Decide the ``Secure`` attribute of the session cookie.
``SECURE_COOKIES`` stays authoritative when it holds an explicit value:
``true`` always marks the cookie Secure (the documented knob for a TLS
proxy), ``false`` never does, which is the escape hatch for an install
that still answers on plain HTTP alongside HTTPS. Anything else —
unset, or the present-but-empty value docker-compose injects for a
variable the host has not defined — derives it from the request, so an
HTTPS login gets a Secure cookie without any configuration.
Either the connection scheme or ``X-Forwarded-Proto`` saying https is
enough, which is the same test ``core/middleware.py`` applies before it
sends HSTS. Uvicorn's proxy-headers middleware already folds that header
into the scheme for the proxies it trusts, so reading it here only adds
the case of a terminator that is not on a trusted address; the cost is
that a client talking to the app directly can set the header and lock
its own session out over plain HTTP.
"""
configured = os.getenv("SECURE_COOKIES", "").strip().lower()
if configured in ("true", "false"):
return configured == "true"
# A chained proxy sends a list — the client-facing hop comes first.
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0]
return request.url.scheme == "https" or forwarded_proto.strip().lower() == "https"
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -159,7 +186,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
value=token,
httponly=True,
samesite="lax",
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
secure=_secure_cookie(request),
path="/",
)
if body.remember:
+20 -5
View File
@@ -624,6 +624,8 @@ async def build_chat_context(
agent_mode: bool = False,
allow_tool_preprocessing: bool = True,
defer_context_shaping: bool = False,
continuation_context_message: str | None = None,
persist_user_message: bool = True,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
@@ -647,14 +649,14 @@ async def build_chat_context(
# Add user message to history. Nobody/incognito uses a request-local
# transcript store instead of session history so stale saved chats cannot
# bleed into context and the turn is not persisted.
if incognito:
if persist_user_message and incognito:
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
else:
elif persist_user_message:
add_user_message(sess, chat_handler, preprocessed, incognito=False)
# Fire events
if not incognito:
if persist_user_message and not incognito:
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
@@ -666,7 +668,12 @@ async def build_chat_context(
getattr(chat_handler, "upload_handler", None),
getattr(sess, "owner", None),
)
casual_low_signal = _is_casual_low_signal(message)
context_message = (
str(continuation_context_message).strip()
if continuation_context_message
else message
)
casual_low_signal = _is_casual_low_signal(context_message)
# Memory enabled?
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
@@ -703,7 +710,15 @@ async def build_chat_context(
# Build context preface
# The stream path uses enhanced_message (with CoT/preprocessing applied),
# the sync path uses text_for_context.
_ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context
_ctx_msg = (
context_message
if continuation_context_message
else (
preprocessed.enhanced_message
if use_enhanced_message
else preprocessed.text_for_context
)
)
_preface_kwargs = dict(
message=_ctx_msg,
session=sess,
+209 -10
View File
@@ -67,6 +67,7 @@ from src.tool_policy import (
is_web_search_explicitly_denied,
web_search_enabled_for_turn,
)
from src.tool_approvals import tool_approval_store
logger = logging.getLogger(__name__)
@@ -88,6 +89,65 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
return None
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
"""Persist a consumed approval decision on its existing tool event."""
approval_key = str(approval_id or "")
normalized_decision = str(decision or "").strip().lower()
if not approval_key or normalized_decision not in {"approve", "approve_task", "deny"}:
return False
message_id = None
resolved_metadata = None
for item in reversed(getattr(sess, "history", []) or []):
metadata = getattr(item, "metadata", None)
if not isinstance(metadata, dict):
continue
tool_events = metadata.get("tool_events")
if not isinstance(tool_events, list):
continue
for event in reversed(tool_events):
ask_user = event.get("ask_user") if isinstance(event, dict) else None
if not isinstance(ask_user, dict):
continue
if str(ask_user.get("approval_id") or "") != approval_key:
continue
ask_user["resolved"] = normalized_decision
message_id = metadata.get("_db_id")
resolved_metadata = {
key: value for key, value in metadata.items() if key != "_db_id"
}
break
if resolved_metadata is not None:
break
if resolved_metadata is None or not message_id:
return False
db = SessionLocal()
try:
db_message = db.query(DBChatMessage).filter(
DBChatMessage.id == message_id,
DBChatMessage.session_id == str(getattr(sess, "id", "")),
).first()
if db_message is None:
return False
db_message.meta_data = json.dumps(resolved_metadata)
db.commit()
return True
except Exception:
db.rollback()
logger.exception("Failed to persist tool approval resolution")
return False
finally:
db.close()
async def _tool_approval_resolution_stream(decision: str) -> AsyncGenerator[str, None]:
yield f"data: {json.dumps({'type': 'tool_approval_resolved', 'decision': decision})}\n\n"
yield "data: [DONE]\n\n"
def _chat_candidate_request_factory(
messages,
fallback_context_length: int = 0,
@@ -905,6 +965,19 @@ def setup_chat_routes(
incognito = str(form_data.get("incognito", "")).lower() == "true"
plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true"
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
tool_approval_id = (
form_data.get("tool_approval_id")
or (body or {}).get("tool_approval_id")
)
tool_approval_decision = (
form_data.get("tool_approval_decision")
or (body or {}).get("tool_approval_decision")
)
exact_tool_approval = None
pending_tool_approval = None
retired_tool_approval_taint = False
external_untrusted_context_seen = False
tool_approval_continuation = False
# Workspace: confine the agent's file/shell tools to this folder.
workspace, workspace_rejected = _resolve_request_workspace(
request, form_data.get("workspace")
@@ -1037,20 +1110,106 @@ def setup_chat_routes(
)
try:
# Attachment-only sends: skip the message-required check when the
# user has attached one or more files (the attachment IS the action).
# Attachment-only sends and approval controls may omit message text.
_has_atts = (
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
or bool(form_data.get("attachments"))
)
message, session = coerce_message_and_session(
body, message, session, session_manager, allow_empty=_has_atts,
body, message, session, session_manager,
allow_empty=(_has_atts or bool(tool_approval_id)),
)
# Verify ownership AFTER coerce (which may resolve a default session)
# but BEFORE loading. Prevents cross-user session hijack.
_verify_session_owner(request, session)
sess = session_manager.get_session(session)
owner = effective_user(request)
if tool_approval_id:
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
normalized_owner = str(owner or "").strip().casefold()
if (
pending_tool_approval is None
or pending_tool_approval.owner != normalized_owner
or pending_tool_approval.session_id != str(session)
):
raise HTTPException(
409,
"This tool approval is invalid, expired, or belongs to another thread.",
)
pending_taint = bool(
pending_tool_approval.external_untrusted_context_seen
)
external_untrusted_context_seen = (
external_untrusted_context_seen or pending_taint
)
decision = str(tool_approval_decision or "").strip().lower()
if decision not in {"approve", "approve_task", "deny"}:
raise HTTPException(400, "Invalid tool approval decision.")
if plan_mode:
raise HTTPException(
409,
"Tool approvals cannot be consumed while plan mode is active.",
)
exact_tool_approval = tool_approval_store.consume(
tool_approval_id,
decision=decision,
owner=owner,
session_id=session,
)
tool_approval_continuation = True
if (
decision in {"approve", "approve_task"}
and exact_tool_approval is None
):
raise HTTPException(
409,
"This tool approval could not be consumed.",
)
if not _mark_tool_approval_resolved(
sess,
tool_approval_id,
decision,
):
logger.warning(
"Tool approval %s was consumed but its persisted card could not be marked resolved",
tool_approval_id,
)
if decision == "deny":
return StreamingResponse(
_tool_approval_resolution_stream(decision),
media_type="text/event-stream",
)
# Approval is a control-plane continuation, not a new user turn.
# Reuse the sealed interrupted request only for internal context,
# retrieval, and policy reconstruction; never persist or display it.
message = pending_tool_approval.continuation_query
# The sealed server record, not mutable composer state,
# restores the original action workspace.
workspace = pending_tool_approval.workspace or None
workspace_rejected = None
if pending_tool_approval.document_id:
active_doc_id = pending_tool_approval.document_id
# Restore only the coarse request toggle needed by the exact
# sealed action. Current privilege, global-disable, incognito,
# compare, and tool-policy gates still run.
if pending_tool_approval.tool_name == "bash":
allow_bash = "true"
if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
allow_web_search = "true"
_search_enabled = True
chat_mode = "agent"
else:
# A normal user message supersedes the card that was waiting
# in this thread. Retire its opaque grant, but preserve the
# originating provenance for this turn so dismissing a card
# cannot make the same model-requested action authoritative.
retired_tool_approval_taint = tool_approval_store.retire_for_session(
owner=owner,
session_id=session,
)
external_untrusted_context_seen = (
external_untrusted_context_seen or retired_tool_approval_taint
)
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
if _clear_orphaned_session_endpoint(sess, owner=owner):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
@@ -1118,14 +1277,24 @@ def setup_chat_routes(
resolve_session_auth(sess, session, owner=effective_user(request))
# Check for research_pending BEFORE mode persist overwrites it
do_research = str(use_research).lower() == "true"
if not do_research:
# An approval response resumes the sealed agent action. Do not let
# mutable form fields, or a stale research_pending session marker,
# consume the one-use grant on the unrelated research path.
do_research = (
not tool_approval_continuation
and str(use_research).lower() == "true"
)
if not do_research and not tool_approval_continuation:
if get_session_mode(session) == 'research_pending':
do_research = True
logger.info(f"Session {session} in research_pending — auto-triggering research")
att_ids = []
if body and isinstance(body.get("attachments"), list):
if tool_approval_continuation:
# Browser composer state is unrelated to the action that was
# reviewed. The original turn remains in session history.
att_ids = []
elif body and isinstance(body.get("attachments"), list):
att_ids = [str(x) for x in body["attachments"]]
elif attachments:
try:
@@ -1170,6 +1339,14 @@ def setup_chat_routes(
agent_mode=(chat_mode == "agent"),
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
continuation_context_message=(
pending_tool_approval.continuation_query
if exact_tool_approval
and pending_tool_approval
and pending_tool_approval.continuation_query
else None
),
persist_user_message=not tool_approval_continuation,
)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -1572,7 +1749,11 @@ def setup_chat_routes(
if foreground_policy.enabled
else ctx.messages
)
messages = _ensure_current_request_is_latest_user(context_source, message)
messages = (
list(context_source)
if tool_approval_continuation
else _ensure_current_request_is_latest_user(context_source, message)
)
# Auto-compact notification
if ctx.was_compacted:
@@ -2043,7 +2224,10 @@ def setup_chat_routes(
incognito=incognito, compare_mode=compare_mode,
character_name=ctx.preset.character_name,
owner=_user,
allow_background_extraction=not tool_policy.block_all_tool_calls,
allow_background_extraction=(
not tool_policy.block_all_tool_calls
and not tool_approval_continuation
),
)
_stream_set(session, status="done")
yield chunk
@@ -2132,9 +2316,18 @@ def setup_chat_routes(
plan_mode=plan_mode,
approved_plan=approved_plan or None,
workspace=workspace or None,
relevant_tools=(
set(pending_tool_approval.selected_tools)
if exact_tool_approval
and pending_tool_approval
and pending_tool_approval.selected_tools
else None
),
forced_tools=_forced_tools,
uploaded_files=ctx.uploaded_files,
defer_context_shaping=_foreground_policy.enabled,
external_untrusted_context_seen=external_untrusted_context_seen,
exact_approval=exact_tool_approval,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
@@ -2301,8 +2494,14 @@ def setup_chat_routes(
agent_tool_calls=_agent_tool_calls,
skills_manager=skills_manager,
owner=_user,
extract_skills=user_requested_agent,
allow_background_extraction=not tool_policy.block_all_tool_calls,
extract_skills=(
user_requested_agent
and not tool_approval_continuation
),
allow_background_extraction=(
not tool_policy.block_all_tool_calls
and not tool_approval_continuation
),
)
_stream_set(session, status="done")
yield chunk
+35
View File
@@ -1204,6 +1204,41 @@ def _safe_env_prefix(ep: str | None) -> str | None:
return f'[ -f "{path}" ] && source "{path}" || true'
def _local_windows_bash_env_prefix(ep: str | None) -> str | None:
"""Convert a frontend PowerShell venv prefix for the local Git Bash runner."""
if not ep:
return ep
prefix = ep.strip()
if not prefix.startswith("&"):
return ep
raw_path = prefix[1:].lstrip()
if not raw_path:
return ep
if raw_path.startswith("'"):
if len(raw_path) < 2 or not raw_path.endswith("'"):
return ep
quoted_path = raw_path[1:-1]
if "'" in quoted_path.replace("''", ""):
return ep
path = quoted_path.replace("''", "'")
else:
path = raw_path.rstrip()
if "'" in path or '"' in path:
return ep
if any(c in path for c in "\r\n;&|`$<>"):
return ep
if not path.replace("\\", "/").casefold().endswith("/scripts/activate.ps1"):
return ep
bash_path = _git_bash_path(path)
if "\\" in bash_path:
return ep
bash_path = bash_path[: -len("Activate.ps1")] + "activate"
return "source " + shlex.quote(bash_path)
def _ssh_ps(host, script_path, port=None):
"""Build SSH command to run a PowerShell script on a Windows remote."""
pf = f"-p {port} " if port and port != "22" else ""
+3 -3
View File
@@ -50,7 +50,7 @@ from routes.cookbook_helpers import (
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
_validate_local_dir, _validate_gpus, _shell_path,
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT,
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
_safe_env_prefix, _local_windows_bash_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
load_stored_hf_token,
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
@@ -1336,7 +1336,7 @@ def setup_cookbook_routes() -> APIRouter:
# Local: run hf download in the background (tmux on POSIX, a detached
# process + logfile on Windows where tmux doesn't exist).
if req.env_prefix:
lines.append(_safe_env_prefix(req.env_prefix))
lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
else:
lines.append("deactivate 2>/dev/null; hash -r")
# Show whether the HF token reached this run (masked) — tells a gated
@@ -2166,7 +2166,7 @@ def setup_cookbook_routes() -> APIRouter:
if req.gpus:
runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'")
if req.env_prefix:
runner_lines.append(_safe_env_prefix(req.env_prefix))
runner_lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
else:
runner_lines.append("deactivate 2>/dev/null; hash -r")
_append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd)
+13 -7
View File
@@ -475,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
return RedirectResponse(auth_url)
else:
# Remote device — show paste-back page
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, redirect_uri))
finally:
db.close()
@@ -612,15 +612,13 @@ def setup_mcp_routes(mcp_manager: McpManager):
def _oauth_authorize_page(
auth_url: str,
server_id: str,
host: str,
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
redirect_uri: str,
) -> str:
"""Page with Google sign-in link and URL paste-back form for remote access."""
# Escape values interpolated into the page: `host` comes from the request
# Host header and `server_id` from the OAuth state — neither is trusted.
# Escape values interpolated into the page: `server_id` comes from the OAuth
# state and is not trusted.
auth_url = html.escape(auth_url, quote=True)
server_id = html.escape(server_id, quote=True)
host = html.escape(host, quote=True)
redirect_uri = html.escape(redirect_uri, quote=True)
return f"""<!DOCTYPE html>
<html><head>
@@ -664,7 +662,15 @@ def _oauth_authorize_page(
</div>
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
<div class="divider"></div>
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
<!-- Relative action: the browser resolves it against the origin this page was
served from, so the form follows the user through any proxy without the
app having to know the scheme or the host. An absolute http:// action is
blocked as mixed content on exactly the HTTPS deployments that need
paste-back, and request.url.scheme cannot be trusted to spot them
uvicorn only honours X-Forwarded-Proto from a peer in
--forwarded-allow-ips, which defaults to 127.0.0.1 and excludes a proxy
arriving over the Docker bridge. -->
<form method="POST" action="/api/mcp/oauth/exchange/{server_id}">
<p>Paste the URL from your browser after signing in:</p>
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
<br><button type="submit">Connect</button>
+5 -7
View File
@@ -1351,14 +1351,14 @@ def _legacy_visible_api_models(ep) -> List[str]:
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
"""Return model IDs that should appear in the picker for an endpoint.
API providers expose remote inventory from /v1/models. Treat that cache as
inventory, not approval: only manually pinned API models should appear in
the picker. Local/self-hosted endpoints keep the older hide-list behavior.
API providers expose remote inventory from /v1/models. Default to that
visible inventory until an explicit pinned-model allow-list is saved.
Local/self-hosted endpoints keep the older hide-list behavior.
"""
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
if _picker_requires_pinning(base_url, kind):
if not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep) if _hidden_model_ids(ep) else []
pinned = _legacy_visible_api_models(ep)
return pinned, pinned
return _visible_models(
_cached_model_ids(ep),
@@ -2342,9 +2342,7 @@ def setup_model_routes(model_discovery):
else:
response.headers["X-Model-Refresh-Status"] = "failed"
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
if picker_requires_pinning and not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep)
_, pinned = _picker_models_for_endpoint(ep, base, kind)
pinned_set = set(pinned)
return [
{
+2 -2
View File
@@ -15,7 +15,7 @@ from pydantic import BaseModel, Field
from core.middleware import INTERNAL_TOOL_USER
from src.endpoint_resolver import resolve_endpoint
from src.auth_helpers import _auth_disabled, get_current_user
from core.auth import RESERVED_USERNAMES
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.constants import DEEP_RESEARCH_DIR
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
@@ -496,7 +496,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
user = require_privilege(request, "can_use_research")
if user == INTERNAL_TOOL_USER:
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
if tool_owner and tool_owner not in RESERVED_USERNAMES:
if tool_owner and tool_owner not in REQUEST_SENTINEL_OWNERS:
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
try:
+254 -17
View File
@@ -18,6 +18,7 @@ from pydantic import BaseModel, Field
from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user
from src.prompt_security import untrusted_context_message
from core.middleware import require_admin
logger = logging.getLogger(__name__)
@@ -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,
url: str, model: str, headers: Optional[dict]) -> dict:
"""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 = {}
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
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
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:
return
log = job["log"]
transcript = []
transcript = transcript if isinstance(transcript, list) else []
say_buf = []
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)})
say_buf.clear()
messages = [
{"role": "system", "content":
"You are TESTING a skill. Below is a reusable skill (a procedure). Follow it "
"to complete the user's task for real, using your available tools, step by "
"step. If the skill is wrong, unclear, or references tools that don't exist, "
"do your best — the problems will be reviewed afterward.\n\n=== SKILL ===\n" + md},
{"role": "user", "content": task},
]
messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
try:
async for chunk in stream_agent_loop(
url, model, messages, headers=headers,
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
exact_approval=exact_approval,
):
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
continue
@@ -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":
_flush_say()
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")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
and approval.get("approval_id")
):
# Manual skill tests have their own polling UI instead of a
# chat session. Pause the run and retain only server-side
# continuation state until the same owner approves/denies
# this exact sealed action.
job["status"] = "awaiting_approval"
job["approval"] = approval
job["_transcript"] = transcript
return
elif d.get("type") == "agent_step":
_flush_say()
log.append({"type": "agent_step", "round": d.get("round")})
@@ -471,6 +514,9 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
_flush_say()
log.append({"type": "error", "error": str(e)})
job.pop("approval", None)
job.pop("_transcript", None)
job.pop("_run", None)
log.append({"type": "evaluating"})
try:
job["verdict"] = await _eval_skill_run(md, task, "".join(transcript), url, model, headers)
@@ -694,12 +740,8 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
import json as _json
from src.agent_loop import stream_agent_loop
transcript = []
messages = [
{"role": "system", "content":
"You are TESTING a skill. Follow this skill's procedure to complete the task "
"for real, using your tools, step by step.\n\n=== SKILL ===\n" + md},
{"role": "user", "content": task},
]
approval_required = None
messages = _skill_test_messages(md, task)
try:
# max_tokens explicitly set: passing 0 lets some upstreams (Ollama,
# 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")
elif d.get("type") == "tool_output":
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
approval = d.get("ask_user")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
):
approval_required = approval
break
elif d.get("type") == "agent_step":
transcript.append(f"\n--- round {d.get('round')} ---\n")
except Exception as e:
transcript.append(f"\n[run error] {e}\n")
text = "".join(transcript)
if approval_required is not None:
# Unattended audits have no authority to approve and no UI that could
# resume this record. Destructively deny it now instead of leaving a
# reusable opaque grant pending until TTL/cap eviction.
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
approval_required.get("approval_id"),
decision="deny",
owner=owner,
session_id=None,
)
except Exception:
logger.debug("Could not retire unattended skill approval", exc_info=True)
return text, {
"verdict": "inconclusive",
"confidence": 1.0,
"summary": (
"This automated audit reached an exact action that requires "
"a human approval; no action was executed."
),
"issues": [
"Run this skill's manual test and review the sealed action."
],
"approval_required": True,
}
verdict = await _eval_skill_run(md, task, text, url, model, headers)
return text, verdict
@@ -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)
v = verdict.get("verdict")
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
if verdict.get("approval_required"):
# An unattended audit is not authority for an action influenced by the
# skill under test. Preserve the skill's current publication/confidence
# state and route the exact action to the manual test UI instead of
# letting a safety pause demote, rewrite, or auto-publish the skill.
skills_manager.set_audit(
name,
"inconclusive",
by_teacher=False,
worker_model=model,
owner=owner,
)
status = skill.get("status") or "draft"
log(f"{name}: {status} unchanged — exact action needs manual approval")
return {
"skill": name,
"result": "approval_required",
"verdict": verdict,
"status": status,
}
if v == "pass":
# Procedure works. If the reviewer still flagged metadata (tags/category/
# when_to_use/description), do ONE fixer pass to correct the frontmatter
@@ -1431,6 +1526,19 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
logger.warning(f"Skill-test model resolve failed: {_e}")
key = (user or "", name)
previous_job = _skill_test_jobs.get(key) or {}
previous_approval = previous_job.get("approval") or {}
if previous_approval.get("approval_id"):
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
previous_approval["approval_id"],
decision="deny",
owner=user,
session_id=None,
)
except Exception:
logger.debug("Could not retire replaced skill approval", exc_info=True)
_skill_test_jobs[key] = {
"status": "running",
"task": task,
@@ -1439,10 +1547,138 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"started": _time.time(),
"log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}],
"verdict": None,
"_run": {
"md": md,
"url": url,
"model": model,
"headers": headers,
"owner": user,
},
}
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
return {"ok": True, "status": "running", "skill": name, "model": model}
@router.post("/{skill_id}/test-approval")
async def approve_skill_test_action(request: Request, skill_id: str):
"""Resume a manual skill test with one exact server-sealed action."""
import asyncio as _asyncio
from src.tool_approvals import tool_approval_store
user = _owner(request)
skills = skills_manager.load(owner=user)
match = next(
(s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id),
None,
)
if not match:
raise HTTPException(404, "Skill not found")
_verify_owner(match, user)
name = match.get("name")
key = (user or "", name)
job = _skill_test_jobs.get(key)
if not job or job.get("status") != "awaiting_approval":
raise HTTPException(409, "This skill test is not awaiting an approval.")
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Tool approval body must be a JSON object.")
approval_id = str(body.get("approval_id") or "")
decision = str(body.get("decision") or "").strip().lower()
expected = job.get("approval") or {}
if approval_id != str(expected.get("approval_id") or ""):
raise HTTPException(409, "This approval does not match the pending skill test action.")
if decision not in {"approve", "deny"}:
raise HTTPException(400, "Invalid tool approval decision.")
pending = tool_approval_store.peek(approval_id)
normalized_owner = str(user or "").strip().casefold()
if (
pending is None
or pending.owner != normalized_owner
or pending.session_id != ""
):
raise HTTPException(409, "This tool approval is invalid or expired.")
exact_approval = tool_approval_store.consume(
approval_id,
decision=decision,
owner=user,
session_id=None,
# The button here says "Allow once" and there is no chat to carry a
# scope into, so the gate must re-arm behind the sealed action.
allow_continuation=False,
)
if decision == "approve" and exact_approval is None:
raise HTTPException(409, "This tool approval could not be consumed.")
job.pop("approval", None)
if decision == "deny":
job.pop("_transcript", None)
job.pop("_run", None)
job["log"].append({
"type": "approval_denied",
"text": "Exact action denied; the skill test stopped without executing it.",
})
job["verdict"] = {
"verdict": "inconclusive",
"confidence": 1.0,
"summary": "The test stopped because its exact action was denied.",
"issues": [],
}
job["status"] = "done"
return {"ok": True, "status": "done", "decision": "deny"}
run = job.get("_run") or {}
transcript = job.pop("_transcript", [])
# stream_agent_loop owns its per-round message list internally. Rebuild
# continuation context from the original untrusted skill plus the
# accumulated transcript so repeated approvals do not lose earlier
# approved results, while keeping every transcript byte tainted.
messages = _skill_test_messages(
run.get("md", ""),
job.get("task", ""),
)
if transcript:
messages.append(untrusted_context_message(
"skill test transcript",
"".join(str(item) for item in transcript),
))
messages.extend([
{
"role": "assistant",
"content": str(expected.get("question") or "Allow this exact action once?"),
},
{
"role": "user",
"content": (
f"Approved the exact {exact_approval.pending.tool_name} "
"action shown above once."
),
},
])
job["status"] = "running"
job["log"].append({
"type": "approval_granted",
"text": (
f"Approved exact {exact_approval.pending.tool_name} action once; "
"resuming test."
),
})
_asyncio.create_task(_run_skill_test_job(
key,
name,
run.get("md", ""),
job.get("task", ""),
run.get("url"),
run.get("model"),
run.get("headers"),
run.get("owner"),
skills_manager,
messages=messages,
transcript=transcript,
exact_approval=exact_approval,
))
return {"ok": True, "status": "running", "decision": "approve"}
@router.get("/{skill_id}/test-status")
async def test_skill_status(request: Request, skill_id: str):
"""Current background-test state for a skill (status / log / verdict)."""
@@ -1459,6 +1695,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"model": job.get("model"),
"log": job.get("log", []),
"verdict": job.get("verdict"),
"approval": job.get("approval"),
}
@router.post("/audit-all")
+5
View File
@@ -0,0 +1,5 @@
"""Task route domain package (slice 2p, #4082/#4071).
Contains task_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/task_routes.py re-exports from here.
"""
File diff suppressed because it is too large Load Diff
+14 -1177
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -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))
+7 -2
View File
@@ -327,7 +327,12 @@ def list_models():
@app.post("/v1/images/generations")
def generate(req: ImageRequest):
model = req.model or _args.model
# The served model is the one this process was launched with. `req.model`
# is accepted for OpenAI wire compatibility and ignored, matching
# scripts/diffusion_server.py: honouring it would let a caller point the
# generator at any local directory or Hugging Face repo, and the HiDream
# branch runs a python script from inside that directory.
model = _args.model
width, height = _size(req.size)
out_images = []
count = max(1, min(int(req.n or 1), 4))
@@ -393,7 +398,7 @@ async def edit_image(
size: str = Form("1024x1024"),
response_format: str = Form("b64_json"),
):
active_model = model or _args.model
active_model = _args.model # pinned; see generate()
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
image_raw = await image.read()
mask_raw = await mask.read() if mask is not None else None
+11 -3
View File
@@ -2,7 +2,7 @@
"""odysseus-webhook — shell wrapper for scheduled-task webhook tokens.
Tasks in the scheduled-task system can carry a `webhook_token`. Any
HTTP POST to `/api/webhook/<token>` fires the task. This CLI lists,
HTTP POST to `/api/tasks/<task-id>/webhook/<token>` fires the task. This CLI lists,
rotates, and revokes those tokens.
odysseus-webhook list # tasks that have a token
@@ -21,6 +21,7 @@ quiet_logs()
import argparse, json, logging, os, secrets, sys
from pathlib import Path
from urllib.parse import quote
try:
from core.database import SessionLocal, ScheduledTask
@@ -53,6 +54,14 @@ def _summary(t: "ScheduledTask", reveal: bool = False) -> dict:
}
def _task_webhook_url(base: str, task_id: str, token: str) -> str:
"""Build the live task-route URL without leaking ids into path syntax."""
root = (base or "http://localhost:7000").rstrip("/")
task_part = quote(str(task_id), safe="")
token_part = quote(str(token), safe="")
return f"{root}/api/tasks/{task_part}/webhook/{token_part}"
def cmd_list(args):
db = SessionLocal()
try:
@@ -109,8 +118,7 @@ def cmd_url(args):
fail(f"no task with id {args.id!r}")
if not t.webhook_token:
fail(f"task {args.id!r} has no webhook token (rotate one first)")
base = (args.base or "http://localhost:7000").rstrip("/")
url = f"{base}/api/webhook/{t.webhook_token}"
url = _task_webhook_url(args.base, t.id, t.webhook_token)
emit({
"task_id": t.id,
"name": t.name,
+41 -11
View File
@@ -50,16 +50,46 @@ class DocsService:
List of DocChunk objects
"""
results = self.rag.search(query, k=top_k)
return [
DocChunk(
text=r.get("text", r.get("content", "")),
source=r.get("source", r.get("metadata", {}).get("source", "unknown")),
score=r.get("score", 0.0),
metadata=r.get("metadata"),
chunks = []
for result in results:
if not isinstance(result, dict):
continue
metadata = result.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
text = result.get("document")
if text is None:
text = result.get("text")
if text is None:
text = result.get("content")
if text is None:
text = ""
source = result.get("source")
if source is None:
source = metadata.get("source")
if source is None:
source = "unknown"
score = result.get("similarity")
if score is None:
score = result.get("score")
if score is None:
score = 0.0
chunks.append(
DocChunk(
text=text,
source=source,
score=score,
metadata=metadata,
)
)
for r in results
if isinstance(r, dict)
]
return chunks
async def index(self, directory: str) -> IndexResult:
"""
@@ -73,8 +103,8 @@ class DocsService:
"""
result = self.rag.index_personal_documents(directory)
return IndexResult(
indexed=result.get("indexed", 0),
failed=result.get("failed", 0),
indexed=result.get("indexed_count", result.get("indexed", 0)),
failed=result.get("failed_count", result.get("failed", 0)),
errors=result.get("errors", []),
)
+31 -331
View File
@@ -2,22 +2,18 @@
import copy
import io
import ipaddress
import json
import os
import re
import logging
import socket
import ssl
from datetime import datetime, timedelta
from typing import Iterable, List, cast
from urllib.parse import urljoin, urlparse
from typing import List
import httpx
import httpcore
from bs4 import BeautifulSoup
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
from src import outbound_fetch as _outbound_fetch
from .analytics import RateLimitError, error_logger
from .cache import (
@@ -29,336 +25,40 @@ from .cache import (
logger = logging.getLogger(__name__)
_PRIVATE_NETWORKS = (
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
)
def _is_private_address(addr):
return _outbound_fetch._is_private_address(addr)
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
return (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_reserved
or addr.is_multicast
or addr.is_unspecified
or any(addr in net for net in _PRIVATE_NETWORKS)
def _resolve_hostname_ips(hostname):
return _outbound_fetch._resolve_hostname_ips(hostname)
def _public_http_url(url):
return _outbound_fetch._public_http_url(url, resolver=_resolve_hostname_ips)
def _resolve_public_ips(url):
return _outbound_fetch._resolve_public_ips(url, resolver=_resolve_hostname_ips)
_PinnedBackend = _outbound_fetch._PinnedBackend
_PinnedTransport = _outbound_fetch._PinnedTransport
BodyTooLargeError = _outbound_fetch.BodyTooLargeError
_CappedFetch = _outbound_fetch._CappedFetch
def _get_public_url(url, headers, timeout, max_redirects=5, max_bytes=None):
return _outbound_fetch._get_public_url(
url,
headers=headers,
timeout=timeout,
max_redirects=max_redirects,
max_bytes=max_bytes,
resolve_public_ips=_resolve_public_ips,
transport_factory=_PinnedTransport,
)
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
try:
infos = socket.getaddrinfo(hostname, None)
except Exception:
return []
out = []
for info in infos:
try:
out.append(ipaddress.ip_address(info[4][0]))
except Exception:
continue
return out
def _public_http_url(url: str) -> bool:
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
host = (parsed.hostname or "").strip()
if not host:
return False
lower = host.lower()
if lower in ("localhost", "metadata", "metadata.google.internal"):
return False
if lower.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
return False
try:
return not _is_private_address(ipaddress.ip_address(host))
except ValueError:
pass
addrs = _resolve_hostname_ips(host)
return bool(addrs) and not any(_is_private_address(a) for a in addrs)
except Exception:
return False
def _resolve_public_ips(url: str) -> list[ipaddress._BaseAddress]:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise httpx.RequestError(f"Blocked non-public URL: {url}")
host = (parsed.hostname or "").strip().lower()
if host in ("localhost", "metadata", "metadata.google.internal"):
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
try:
ip = ipaddress.ip_address(host)
if _is_private_address(ip):
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
return [ip]
except httpx.RequestError:
raise
except ValueError:
pass
addrs = _resolve_hostname_ips(host)
if not addrs or any(_is_private_address(a) for a in addrs):
raise httpx.RequestError(f"Blocked non-public URL: {url}")
return addrs
class _PinnedBackend(httpcore.NetworkBackend):
"""Network backend that connects to a pre-resolved IP.
httpcore derives the TLS SNI and the ``Host`` header from the URL's
origin, not from the host argument passed to ``connect_tcp``. So
routing the TCP connect to a resolved IP while leaving the URL
untouched keeps SNI / vhost behaviour correct and closes the
DNS-rebinding TOCTOU between the SSRF check and the connect.
"""
def __init__(self, ip: ipaddress._BaseAddress):
self._ip = str(ip)
self._real = httpcore.SyncBackend()
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options=None,
):
return self._real.connect_tcp(
self._ip, port, timeout, local_address, socket_options
)
def connect_unix_socket(self, path, timeout=None, socket_options=None):
return self._real.connect_unix_socket(path, timeout, socket_options)
def sleep(self, seconds: float) -> None:
return self._real.sleep(seconds)
# Map httpcore exception classes to their httpx equivalents. Built
# once at import time from the public exception classes; avoids any
# import of httpx's private transport machinery. httpcore's
# ``ConnectionNotAvailable`` is a pool-internal signal (the pool will
# close and retry on its own) — we never expect to see it surface to
# a transport caller, so it has no httpx counterpart here.
_HTTPCORE_TO_HTTPX_EXC = {
httpcore.ConnectError: httpx.ConnectError,
httpcore.ConnectTimeout: httpx.ConnectTimeout,
httpcore.LocalProtocolError: httpx.LocalProtocolError,
httpcore.NetworkError: httpx.NetworkError,
httpcore.PoolTimeout: httpx.PoolTimeout,
httpcore.ProtocolError: httpx.ProtocolError,
httpcore.ProxyError: httpx.ProxyError,
httpcore.ReadError: httpx.ReadError,
httpcore.ReadTimeout: httpx.ReadTimeout,
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
httpcore.TimeoutException: httpx.TimeoutException,
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
httpcore.WriteError: httpx.WriteError,
httpcore.WriteTimeout: httpx.WriteTimeout,
}
class _PinnedTransport(httpx.BaseTransport):
"""Transport that pins every TCP connect to a pre-resolved IP.
Uses only the public ``httpcore`` and ``httpx`` APIs no
subclassing of ``httpx.HTTPTransport``, no reads of private
``httpcore.ConnectionPool`` attributes, no imports from
``httpx private transport internals``. The URL is passed through unchanged so SNI
/ vhost work as if httpx had been given the hostname directly;
only the TCP destination is pinned, closing the DNS-rebinding
TOCTOU between the SSRF check and the connect.
"""
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
self._pool = httpcore.ConnectionPool(
ssl_context=ssl.create_default_context(),
http1=True,
http2=http2,
network_backend=_PinnedBackend(ip),
)
def __enter__(self):
self._pool.__enter__()
return self
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
self._pool.__exit__(exc_type, exc_value, traceback)
def handle_request(self, request: httpx.Request) -> httpx.Response:
httpcore_req = httpcore.Request(
method=request.method,
url=httpcore.URL(
scheme=request.url.raw_scheme,
host=request.url.raw_host,
port=request.url.port,
target=request.url.raw_path,
),
headers=request.headers.raw,
content=request.stream,
extensions=request.extensions,
)
try:
httpcore_resp = self._pool.handle_request(httpcore_req)
# Eager materialisation matches the original
# ``response.text`` usage in fetch_webpage_content. The
# sync pool's stream is a plain Iterable[bytes] despite
# the httpcore type hint unioning the async variant.
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
except Exception as exc:
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
if mapped is not None:
raise mapped(str(exc)) from exc
raise
return httpx.Response(
status_code=httpcore_resp.status,
headers=httpcore_resp.headers,
content=content,
extensions=httpcore_resp.extensions,
)
def close(self) -> None:
self._pool.close()
class BodyTooLargeError(Exception):
"""The server declared a body larger than the hard fetch ceiling."""
def __init__(self, url: str, declared_bytes: int):
self.url = url
self.declared_bytes = declared_bytes
super().__init__(
f"response body is {declared_bytes:,} bytes, over the "
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
)
class _CappedFetch:
"""Result of a size-capped streaming GET.
Carries just what fetch_webpage_content needs from an httpx.Response,
plus the cap bookkeeping: the (possibly truncated) body, whether the
cap cut it short, and the size the server declared via Content-Length
(wire bytes; None when absent).
"""
__slots__ = ("status_code", "headers", "content", "truncated",
"declared_bytes", "encoding", "url")
def __init__(self, status_code, headers, content, truncated,
declared_bytes, encoding, url):
self.status_code = status_code
self.headers = headers
self.content = content
self.truncated = truncated
self.declared_bytes = declared_bytes
self.encoding = encoding
self.url = url
@property
def text(self) -> str:
return self.content.decode(self.encoding or "utf-8", errors="replace")
def raise_for_status(self):
if self.status_code >= 400:
request = httpx.Request("GET", self.url)
raise httpx.HTTPStatusError(
f"HTTP {self.status_code} for {self.url}",
request=request,
response=httpx.Response(self.status_code, request=request),
)
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5,
max_bytes: int = None) -> "_CappedFetch":
"""Capped streaming GET with SSRF-guarded, DNS-pinned manual redirects.
Each hop is resolved once, validated as public, and then the actual TCP
connection is pinned to that resolved IP. The request URL is left unchanged
so Host and TLS SNI keep the original hostname.
"""
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
current = url
for _ in range(max_redirects + 1):
ips = _resolve_public_ips(current)
# Force identity transfer-encoding. With gzip/deflate the wire bytes
# and Content-Length can be a small fraction of the decoded body, so a
# tiny compressed response could pass the hard-cap preflight and then
# expand past the ceiling in one decoded chunk before the streamed cap
# below can slice it.
req_headers = dict(headers or {})
req_headers["Accept-Encoding"] = "identity"
with httpx.Client(
headers=req_headers,
timeout=timeout,
follow_redirects=False,
transport=_PinnedTransport(ips[0]),
) as client:
with client.stream("GET", current) as response:
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location")
if not location:
return _CappedFetch(response.status_code, response.headers, b"",
False, None, response.encoding, str(response.url))
current = urljoin(str(response.url), location)
continue
# A server can ignore the identity request and still return a
# compressed body; httpx.iter_bytes would then decode it, and a
# tiny gzip can balloon into one decoded chunk far past the cap.
# Refuse compressed Content-Encoding so the streamed cap stays
# a real memory bound.
enc = (response.headers.get("content-encoding") or "").strip().lower()
if enc and enc != "identity":
raise httpx.RequestError(
f"Refusing compressed response (Content-Encoding: {enc}) after "
"requesting identity: cannot bound decoded body size",
request=httpx.Request("GET", current),
)
declared = None
raw_len = response.headers.get("content-length")
if raw_len and raw_len.isdigit():
declared = int(raw_len)
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
raise BodyTooLargeError(current, declared)
chunks = []
read = 0
truncated = False
for chunk in response.iter_bytes():
read += len(chunk)
if read > cap:
keep = cap - (read - len(chunk))
if keep > 0:
chunks.append(chunk[:keep])
truncated = True
break
chunks.append(chunk)
return _CappedFetch(response.status_code, response.headers,
b"".join(chunks), truncated, declared,
response.encoding, str(response.url))
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
# PDF extraction (optional dependency)
try:
from pdfminer.high_level import extract_text as pdf_extract_text
+576 -181
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional
import logging
import re
from src.constants import MAX_READ_CHARS
from src.tool_approvals import document_content_digest
from src.tool_utils import _parse_tool_args, get_upload_handler
from src.upload_handler import reserve_upload_references
@@ -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()
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
# ---------------------------------------------------------------------------
@@ -454,6 +489,12 @@ class UpdateDocumentTool:
doc = None
if target_id:
doc = _get_owned_document(db, Document, target_id, owner)
if (
not doc
and target_id
and ctx.get("expected_document_version") is not None
):
return _approved_document_version_error(None, ctx)
if not doc:
doc = _most_recent_owned_document(db, Document, owner)
if doc:
@@ -463,6 +504,10 @@ class UpdateDocumentTool:
if not doc:
return {"error": "No documents exist to update"}
version_error = _approved_document_version_error(doc, ctx)
if version_error:
return version_error
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
new_content = _coerce_email_document_content(doc.current_content or "", content) if is_email_doc else content.strip()
if is_email_doc:
@@ -530,6 +575,12 @@ class EditDocumentTool:
doc = None
if target_id:
doc = _get_owned_document(db, Document, target_id, owner)
if (
not doc
and target_id
and ctx.get("expected_document_version") is not None
):
return _approved_document_version_error(None, ctx)
if not doc:
# Fallback: most recently updated document. Avoids "no active doc" errors
# after server restart or when the agent loses track of which doc to edit.
@@ -541,6 +592,10 @@ class EditDocumentTool:
if not doc:
return {"error": "No documents exist to edit"}
version_error = _approved_document_version_error(doc, ctx)
if version_error:
return version_error
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
blank_find_edits = [e for e in edits if not (e.get("find") or "").strip()]
if blank_find_edits:
@@ -677,6 +732,10 @@ class SuggestDocumentTool:
if not doc:
return {"error": f"Document {target_id} not found"}
version_error = _approved_document_version_error(doc, ctx)
if version_error:
return version_error
# Validate that FIND text exists in document
valid = []
for s in suggestions:
+8 -2
View File
@@ -64,7 +64,10 @@ async def chat_with_model(content: str, session_id: Optional[str] = None, owner:
return {"model": model, "response": response}
except Exception as e:
logger.error(f"chat_with_model failed: {e}")
return {"error": f"Failed to get response from {model_spec}: {e}"}
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:
@@ -110,7 +113,10 @@ async def ask_teacher(content: str, session_id: Optional[str] = None, owner: Opt
return {"model": model, "response": response, "teacher": True}
except Exception as e:
logger.error(f"ask_teacher failed: {e}")
return {"error": f"Teacher call failed ({model_spec}): {e}"}
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:
+4 -1
View File
@@ -240,7 +240,10 @@ async def send_to_session(content: str, session_id: Optional[str] = None, owner:
}
except Exception as e:
logger.error(f"send_to_session failed: {e}")
return {"error": f"Failed to send to session: {e}"}
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:
"""Manage sessions: rename, archive, delete, important, truncate, fork.
+6 -1
View File
@@ -66,6 +66,7 @@ class WebSearchTool:
return {
"error": f"web_search failed: {type(e).__name__}: {str(e) or 'no details'}",
"exit_code": 1,
"untrusted_content": True,
}
if progress_cb:
await progress_cb({
@@ -136,7 +137,11 @@ class WebFetchTool:
if not text:
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}
# Tell the model when the download budget cut the body short and how
+24 -6
View File
@@ -324,7 +324,10 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
}
except Exception as e:
logger.error(f"pipeline failed at step {len(step_outputs) + 1}: {e}")
return {"error": f"Pipeline failed at step {len(step_outputs) + 1}: {e}"}
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))
except Exception:
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()
images = data.get("data", [])
@@ -1173,7 +1179,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
except httpx.TimeoutException:
return {"error": "Image generation timed out (300s). The model may be overloaded — try again or use quality=low."}
except Exception as e:
return {"error": f"Image generation error: {str(e)}"}
return {
"error": f"Image generation error: {str(e)}",
"untrusted_content": True,
}
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
except Exception:
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()
image_b64 = fallback_data.get("image")
if not image_b64:
@@ -1394,7 +1406,10 @@ async def do_edit_image(
"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()
images = data.get("data", [])
@@ -1434,7 +1449,10 @@ async def do_edit_image(
except httpx.TimeoutException:
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
except Exception as e:
return {"error": f"Image edit error: {str(e)}"}
return {
"error": f"Image edit error: {str(e)}",
"untrusted_content": True,
}
# ---------------------------------------------------------------------------
+13 -1
View File
@@ -4,6 +4,8 @@ import os
from typing import Optional
from fastapi import Request, HTTPException
from src.owner_identity import auth_disabled, effective_storage_owner
def get_current_user(request: Request) -> Optional[str]:
"""Get current username from request state (set by auth middleware)."""
@@ -56,7 +58,17 @@ def _auth_disabled() -> bool:
"""True when the operator has explicitly turned off auth via .env.
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
three call sites agree on what "off" means."""
return 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:
+20 -9
View File
@@ -15,6 +15,7 @@ import json
import logging
from src import bg_jobs
from src.prompt_security import untrusted_context_message
logger = logging.getLogger(__name__)
@@ -25,6 +26,16 @@ POLL_INTERVAL_S = 5
_FOLLOWUP_MAX_ROUNDS = 12
def _background_result_message(rec):
inject = (
f"[Background job {rec['id']} finished]\n\n"
f"{bg_jobs.result_text(rec)}\n\n"
"Continue the task using this output. Don't repeat work that's already done. "
"If the task is now complete, give the user the final result."
)
return untrusted_context_message("background job output", inject)
async def _drain_agent(sess, messages):
"""Run the agent loop headless against a session. Returns
(final_prose, tool_events) tool_events in the same shape the live chat
@@ -62,13 +73,19 @@ async def _drain_agent(sess, messages):
round_num = d.get("round", round_num)
elif d.get("type") == "tool_output":
# Mirror the live chat's tool_event shape (chat_routes / chatRenderer).
tool_events.append({
tool_event = {
"round": round_num,
"tool": d.get("tool"),
"command": d.get("command"),
"output": d.get("output"),
"exit_code": d.get("exit_code"),
})
}
if isinstance(d.get("ask_user"), dict):
# Preserve exact-approval cards from a tainted background-job
# continuation so the user can authorize the sealed action on
# the next foreground turn instead of losing it headlessly.
tool_event["ask_user"] = d["ask_user"]
tool_events.append(tool_event)
return full, tool_events
@@ -101,14 +118,8 @@ async def _run_followup(rec: dict) -> bool:
except Exception:
pass
inject = (
f"[Background job {rec['id']} finished]\n\n"
f"{bg_jobs.result_text(rec)}\n\n"
"Continue the task using this output. Don't repeat work that's already done. "
"If the task is now complete, give the user the final result."
)
context = sess.get_context_messages()
context.append({"role": "user", "content": inject})
context.append(_background_result_message(rec))
full, tool_events = await _drain_agent(sess, context)
+15 -1
View File
@@ -810,13 +810,27 @@ async def action_tidy_research(owner: str, **kwargs) -> Tuple[str, bool]:
Research history lives entirely in data/deep_research/<id>.json and is NOT
backed by chat-session rows so a file must never be deleted just because
no chat session matches its id. Only prune files that fail to load."""
no chat session matches its id. Only prune files that fail to load.
A broken file has no readable owner stamp, so it cannot be matched against
`owner`. Clearing one is privileged: admins and the single-user operator
(AUTH_ENABLED=false) may, a regular user may not, and neither may anyone
during the pre-setup window before an admin exists.
"""
try:
from pathlib import Path
import json as _json
from src.tool_security import owner_is_admin_or_single_user
research_dir = Path(DEEP_RESEARCH_DIR)
if not research_dir.exists():
raise TaskNoop("no research directory")
if not owner_is_admin_or_single_user(owner):
# Return before the glob rather than filtering inside the loop: the
# loop reports "none broken" off an empty `removed`, which reaches
# Activity as a false report to a user whose files it skipped, and a
# regular user need not read every owner's file to learn it may
# delete none of them.
raise TaskNoop("not permitted to remove unattributable research files")
files = list(research_dir.glob("*.json"))
removed = []
for p in files:
+35 -3
View File
@@ -381,7 +381,10 @@ class ChatProcessor:
)
if len(rag_content) > 10000:
rag_content = rag_content[:10000] + "\n[Truncated]"
preface.append(untrusted_context_message("retrieved documents", rag_content))
preface.append(untrusted_context_message(
"retrieved documents",
rag_content,
))
except Exception as e:
logger.warning(f"RAG retrieval failed: {e}")
@@ -459,12 +462,38 @@ class ChatProcessor:
skip_url_fetch = len(message) > 2000 or len(non_yt_urls) > 3
if not skip_url_fetch:
for url in non_yt_urls:
result = fetch_webpage_content(url)
try:
result = fetch_webpage_content(url)
except Exception:
# The URL and exception can both contain signed-query
# credentials or response-controlled text. Keep the log
# diagnostic stable as well as the model-facing context.
logger.warning("Automatic URL fetch failed while building context")
result = {"success": False, "error": ""}
if result.get('success'):
content = result.get('content', '')[:10000]
preface.append(untrusted_context_message(
f"web page: {url}",
f"Content from {url}:\n\n{content}",
provenance_origin="external",
))
else:
# A failed automatic URL fetch is context too. Never pass
# exception text or response-controlled diagnostics back to
# the model: reduce the result to a small transport-owned
# status and explicitly state that the page was not read.
error = str(result.get("error") or "")
status = "the page was unavailable"
status_match = re.match(r"^HTTP\s+(\d{3})\b", error)
if status_match:
status = f"the server returned HTTP {status_match.group(1)}"
elif error.startswith("TooLarge:"):
status = "the response exceeded the fetch size limit"
elif error.startswith("Rate limit"):
status = "the request was rate limited"
preface.append(untrusted_context_message(
"web page fetch failure",
f"A linked page was not read: {status}.",
))
# Skills index — progressive disclosure. Only injected when the
@@ -488,6 +517,9 @@ class ChatProcessor:
for s in sorted(by_cat[cat], key=lambda x: x["name"]):
desc = s.get("description") or ""
lines.append(f" - {s['name']}: {desc}" if desc else f" - {s['name']}")
preface.append(untrusted_context_message("available skills index", "\n".join(lines)))
preface.append(untrusted_context_message(
"available skills index",
"\n".join(lines),
))
return preface, rag_sources, web_sources
+1 -1
View File
@@ -4,7 +4,7 @@ import os
from src.runtime_paths import get_app_root, get_default_data_dir
APP_VERSION = "1.0.2"
APP_VERSION = "1.0.3"
# Base paths
BASE_DIR = os.path.join(get_app_root(), "")
+8 -1
View File
@@ -719,7 +719,14 @@ async def execute_api_call(
output = f"HTTP {status}\n{formatted}"
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}
+2
View File
@@ -530,6 +530,8 @@ class McpManager:
"stderr": output if is_error else "",
"exit_code": 1 if is_error else 0,
}
if is_error and output:
result_dict["untrusted_content"] = True
if images:
result_dict["images"] = images
return result_dict
+24 -10
View File
@@ -15,18 +15,32 @@ from urllib.parse import urlparse, parse_qs
logger = logging.getLogger(__name__)
def _resolve_redirect_base() -> str:
"""Origin the browser is sent back to after authorizing.
Falls back to the port the app binds natively (APP_PORT, read the same way
by app.py and launcher.py) rather than a fixed 7000: the macOS launcher
defaults to 7860, and a callback on the wrong port reaches nothing. The
hostname stays `localhost` rather than internal_api_base()'s 127.0.0.1 —
this URI is registered with the authorization server (via DCR, or by hand
for Google clients), so changing the host invalidates registrations that
already exist.
"""
return (
os.environ.get("OAUTH_REDIRECT_BASE_URL")
or os.environ.get("APP_PUBLIC_URL")
or f"http://localhost:{os.environ.get('APP_PORT', '7000')}"
).rstrip("/")
# OAuth redirect URI registered with every authorization server via DCR. Loopback
# is allowed for native/desktop clients (RFC 8252); remote users finish via the
# paste-back flow. Deployments not reachable at http://localhost:7000 (custom
# port, reverse proxy, or public domain) must set OAUTH_REDIRECT_BASE_URL (or
# APP_PUBLIC_URL) to their externally reachable origin so the redirect lands back
# on Odysseus. APP_PORT is intentionally not used: it is only the Docker host
# port-map; the app always listens on 7000 inside the container.
_REDIRECT_BASE = (
os.environ.get("OAUTH_REDIRECT_BASE_URL")
or os.environ.get("APP_PUBLIC_URL")
or "http://localhost:7000"
).rstrip("/")
# paste-back flow. Deployments whose externally reachable origin differs from the
# port Odysseus binds — reverse proxy, public domain, or Docker, whose host port
# map is invisible inside the container — must set OAUTH_REDIRECT_BASE_URL (or
# APP_PUBLIC_URL), otherwise the redirect never lands back on Odysseus.
_REDIRECT_BASE = _resolve_redirect_base()
REDIRECT_URI = f"{_REDIRECT_BASE}/api/mcp/oauth/callback"
# How long the background connect waits for the user to authorize before giving up.
+11 -6
View File
@@ -290,17 +290,22 @@ def detect_vendor(base_url: Any = "", endpoint_kind: Any = "") -> str:
return kind_map[kind]
parsed = urlparse(compact_str(base_url))
host = (parsed.hostname or "").lower()
host = (parsed.hostname or "").lower().rstrip(".")
port = parsed.port
if host.endswith("openrouter.ai"):
def host_matches(domain: str) -> bool:
domain = domain.lower().rstrip(".")
return host == domain or host.endswith(f".{domain}")
if host_matches("openrouter.ai"):
return VENDOR_OPENROUTER
if host.endswith("openai.com"):
if host_matches("openai.com"):
return VENDOR_OPENAI
if host.endswith("anthropic.com"):
if host_matches("anthropic.com"):
return VENDOR_ANTHROPIC
if host.endswith("googleapis.com"):
if host_matches("googleapis.com"):
return VENDOR_GOOGLE
if host.endswith("ollama.com") or port == 11434:
if host_matches("ollama.com") or port == 11434:
return VENDOR_OLLAMA
if port == 1234:
return VENDOR_LMSTUDIO
+354
View File
@@ -0,0 +1,354 @@
"""SSRF-guarded synchronous HTTP fetching primitives.
This module owns outbound URL classification, one-resolution-per-hop DNS
pinning, redirects, and response-body budgets. It deliberately has no search
or content-extraction dependencies so callers outside search can reuse the
same transport boundary.
"""
from __future__ import annotations
import ipaddress
import socket
import ssl
from typing import Callable, Iterable, cast
from urllib.parse import urljoin, urlparse
import httpcore
import httpx
from src.constants import WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_SOFT_MAX_BYTES
_PRIVATE_NETWORKS = (
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
)
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
return (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_reserved
or addr.is_multicast
or addr.is_unspecified
or any(addr in net for net in _PRIVATE_NETWORKS)
)
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
try:
infos = socket.getaddrinfo(hostname, None)
except Exception:
return []
out = []
for info in infos:
try:
out.append(ipaddress.ip_address(info[4][0]))
except Exception:
continue
return out
def _public_http_url(
url: str,
*,
resolver: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
) -> bool:
resolver = resolver or _resolve_hostname_ips
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
host = (parsed.hostname or "").strip()
if not host:
return False
lower = host.lower()
if lower in ("localhost", "metadata", "metadata.google.internal"):
return False
if lower.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
return False
try:
return not _is_private_address(ipaddress.ip_address(host))
except ValueError:
pass
addrs = resolver(host)
return bool(addrs) and not any(_is_private_address(a) for a in addrs)
except Exception:
return False
def _resolve_public_ips(
url: str,
*,
resolver: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
) -> list[ipaddress._BaseAddress]:
resolver = resolver or _resolve_hostname_ips
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise httpx.RequestError(f"Blocked non-public URL: {url}")
host = (parsed.hostname or "").strip().lower()
if host in ("localhost", "metadata", "metadata.google.internal"):
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
try:
ip = ipaddress.ip_address(host)
if _is_private_address(ip):
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
return [ip]
except httpx.RequestError:
raise
except ValueError:
pass
addrs = resolver(host)
if not addrs or any(_is_private_address(a) for a in addrs):
raise httpx.RequestError(f"Blocked non-public URL: {url}")
return addrs
class _PinnedBackend(httpcore.NetworkBackend):
"""Network backend that connects to a pre-resolved IP."""
def __init__(self, ip: ipaddress._BaseAddress):
self._ip = str(ip)
self._real = httpcore.SyncBackend()
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options=None,
):
return self._real.connect_tcp(
self._ip, port, timeout, local_address, socket_options
)
def connect_unix_socket(self, path, timeout=None, socket_options=None):
return self._real.connect_unix_socket(path, timeout, socket_options)
def sleep(self, seconds: float) -> None:
return self._real.sleep(seconds)
_HTTPCORE_TO_HTTPX_EXC = {
httpcore.ConnectError: httpx.ConnectError,
httpcore.ConnectTimeout: httpx.ConnectTimeout,
httpcore.LocalProtocolError: httpx.LocalProtocolError,
httpcore.NetworkError: httpx.NetworkError,
httpcore.PoolTimeout: httpx.PoolTimeout,
httpcore.ProtocolError: httpx.ProtocolError,
httpcore.ProxyError: httpx.ProxyError,
httpcore.ReadError: httpx.ReadError,
httpcore.ReadTimeout: httpx.ReadTimeout,
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
httpcore.TimeoutException: httpx.TimeoutException,
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
httpcore.WriteError: httpx.WriteError,
httpcore.WriteTimeout: httpx.WriteTimeout,
}
class _PinnedTransport(httpx.BaseTransport):
"""Transport that pins every TCP connect to a pre-resolved IP."""
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
self._pool = httpcore.ConnectionPool(
ssl_context=ssl.create_default_context(),
http1=True,
http2=http2,
network_backend=_PinnedBackend(ip),
)
def __enter__(self):
self._pool.__enter__()
return self
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
self._pool.__exit__(exc_type, exc_value, traceback)
def handle_request(self, request: httpx.Request) -> httpx.Response:
httpcore_req = httpcore.Request(
method=request.method,
url=httpcore.URL(
scheme=request.url.raw_scheme,
host=request.url.raw_host,
port=request.url.port,
target=request.url.raw_path,
),
headers=request.headers.raw,
content=request.stream,
extensions=request.extensions,
)
try:
httpcore_resp = self._pool.handle_request(httpcore_req)
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
except Exception as exc:
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
if mapped is not None:
raise mapped(str(exc)) from exc
raise
return httpx.Response(
status_code=httpcore_resp.status,
headers=httpcore_resp.headers,
content=content,
extensions=httpcore_resp.extensions,
)
def close(self) -> None:
self._pool.close()
class BodyTooLargeError(Exception):
"""The server declared a body larger than the hard fetch ceiling."""
def __init__(self, url: str, declared_bytes: int):
self.url = url
self.declared_bytes = declared_bytes
super().__init__(
f"response body is {declared_bytes:,} bytes, over the "
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
)
class _CappedFetch:
"""Result of a size-capped streaming GET."""
__slots__ = (
"status_code",
"headers",
"content",
"truncated",
"declared_bytes",
"encoding",
"url",
)
def __init__(
self,
status_code,
headers,
content,
truncated,
declared_bytes,
encoding,
url,
):
self.status_code = status_code
self.headers = headers
self.content = content
self.truncated = truncated
self.declared_bytes = declared_bytes
self.encoding = encoding
self.url = url
@property
def text(self) -> str:
return self.content.decode(self.encoding or "utf-8", errors="replace")
def raise_for_status(self):
if self.status_code >= 400:
request = httpx.Request("GET", self.url)
raise httpx.HTTPStatusError(
f"HTTP {self.status_code} for {self.url}",
request=request,
response=httpx.Response(self.status_code, request=request),
)
def _get_public_url(
url: str,
headers: dict,
timeout: int,
max_redirects: int = 5,
max_bytes: int | None = None,
*,
resolve_public_ips: Callable[[str], list[ipaddress._BaseAddress]] | None = None,
transport_factory: Callable[[ipaddress._BaseAddress], httpx.BaseTransport] | None = None,
) -> _CappedFetch:
"""Capped streaming GET with SSRF-guarded, DNS-pinned redirects."""
resolve_public_ips = resolve_public_ips or _resolve_public_ips
transport_factory = transport_factory or _PinnedTransport
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
current = url
for _ in range(max_redirects + 1):
ips = resolve_public_ips(current)
req_headers = dict(headers or {})
req_headers["Accept-Encoding"] = "identity"
with httpx.Client(
headers=req_headers,
timeout=timeout,
follow_redirects=False,
transport=transport_factory(ips[0]),
) as client:
with client.stream("GET", current) as response:
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location")
if not location:
return _CappedFetch(
response.status_code,
response.headers,
b"",
False,
None,
response.encoding,
str(response.url),
)
current = urljoin(str(response.url), location)
continue
enc = (response.headers.get("content-encoding") or "").strip().lower()
if enc and enc != "identity":
raise httpx.RequestError(
f"Refusing compressed response (Content-Encoding: {enc}) after "
"requesting identity: cannot bound decoded body size",
request=httpx.Request("GET", current),
)
declared = None
raw_len = response.headers.get("content-length")
if raw_len and raw_len.isdigit():
declared = int(raw_len)
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
raise BodyTooLargeError(current, declared)
chunks = []
read = 0
truncated = False
for chunk in response.iter_bytes():
read += len(chunk)
if read > cap:
keep = cap - (read - len(chunk))
if keep > 0:
chunks.append(chunk[:keep])
truncated = True
break
chunks.append(chunk)
return _CappedFetch(
response.status_code,
response.headers,
b"".join(chunks),
truncated,
declared,
response.encoding,
str(response.url),
)
raise httpx.RequestError(
"Too many redirects", request=httpx.Request("GET", current)
)
+56
View File
@@ -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
View File
@@ -61,7 +61,13 @@ def _sanitize_label(label: str) -> str:
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.
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)
text = "" if content is None else str(content)
text = _escape_guard_markers(text)
metadata: Dict[str, Any] = {
"trusted": False,
"source": label,
"tool_gate_untrusted": bool(arm_tool_gate),
}
if provenance_origin:
metadata["provenance_origin"] = provenance_origin
return {
"role": "user",
"content": (
@@ -82,5 +95,5 @@ def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
f"{text}\n"
f"{GUARD_CLOSE}"
),
"metadata": {"trusted": False, "source": label},
"metadata": metadata,
}
+38 -1
View File
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable, Dict, Tuple
from core.auth import RESERVED_USERNAMES
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
@@ -1883,6 +1884,7 @@ class TaskScheduler:
pass
full_text = ""
tool_results = []
approval_pause = None
# Honor per-task max_steps (defense against runaway agent loops).
# Falls back to 20 if not set — the historical default.
@@ -1929,9 +1931,44 @@ class TaskScheduler:
tool_summary = data.get("stdout") or data.get("output") or data.get("result") or ""
if isinstance(tool_summary, str) and tool_summary.strip():
tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}")
approval = data.get("ask_user")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
):
approval_pause = {
"tool": data.get("tool") or "tool",
"approval_id": approval.get("approval_id"),
}
# Scheduled tasks have no interactive surface that
# can safely resume a one-use grant. Retire the
# record immediately instead of leaving it pending
# and report an explicit manual-action boundary.
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
approval_pause["approval_id"],
decision="deny",
owner=task.owner,
session_id=session_id,
)
except Exception:
logger.debug(
"Could not retire scheduled-task approval",
exc_info=True,
)
break
except (json.JSONDecodeError, KeyError):
pass
if approval_pause is not None:
return (
"Scheduled task paused safely: "
f"{approval_pause['tool']} requested an exact action after "
"untrusted context. That action was not executed. Run this task "
"interactively to inspect and approve the action."
)
# Grace summarization — if the model exhausted rounds on tool calls
# without producing a final text response, do one last LLM call
# asking it to summarize what it did. Guarantees output.
@@ -2484,7 +2521,7 @@ class TaskScheduler:
# check-ins seeded, which then double-fire alongside the human user's
# check-ins. This was the root cause of the duplicate 'Morning check-in'
# rows we had to manually clean up.
if not owner or owner in RESERVED_USERNAMES:
if not owner or owner in REQUEST_SENTINEL_OWNERS:
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
return
from core.database import SessionLocal, CrewMember, ScheduledTask
+110 -74
View File
@@ -439,56 +439,11 @@ async def escalate_and_learn(
failure_reason: str,
owner: Optional[str] = None,
) -> Optional[str]:
"""Call the teacher, evaluate ITS attempt, save a skill on success.
Returns the saved skill name (or None if the teacher couldn't
write one). Logs but doesn't raise — escalation is best-effort.
"""
from src.settings import get_setting
teacher_spec = (get_setting("teacher_model", "") or "").strip()
if not teacher_spec:
return None
prompt = _TEACHER_ESCALATION_PROMPT.format(
user_request=user_request or "(no user request captured)",
failure_reason=failure_reason or "(failure reason not captured)",
untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD,
trace=_format_trace(tool_results, agent_reply),
"""Retire legacy background learning when no approval UI is available."""
logger.info(
"background teacher learning skipped: generated skills require an "
"interactive exact approval"
)
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
@@ -563,6 +518,12 @@ async def run_teacher_inline(
student_tool_events: List[Dict[str, Any]],
student_reply: str,
owner: Optional[str] = None,
session_id: Optional[str] = None,
workspace: Optional[str] = None,
disabled_tools: Optional[set[str]] = None,
tool_policy: Any = None,
active_document: Any = None,
active_email: Optional[Dict[str, str]] = None,
):
"""Async generator. Yields SSE event strings.
@@ -661,6 +622,7 @@ async def run_teacher_inline(
from src.agent_loop import stream_agent_loop
captured_tool_events: List[Dict[str, Any]] = []
captured_text_parts: List[str] = []
captured_metrics: Dict[str, Any] = {}
async for evt_str in stream_agent_loop(
endpoint_url=teacher_url,
@@ -668,6 +630,12 @@ async def run_teacher_inline(
messages=teacher_messages,
headers=teacher_headers,
owner=owner,
session_id=session_id,
workspace=workspace,
disabled_tools=disabled_tools,
tool_policy=tool_policy,
active_document=active_document,
active_email=active_email,
_is_teacher_run=True,
):
# Swallow teacher's own [DONE] — outer loop emits the real one
@@ -682,13 +650,21 @@ async def run_teacher_inline(
if isinstance(payload, dict):
payload["teacher"] = True
typ = payload.get("type")
if typ == "metrics" and isinstance(payload.get("data"), dict):
# The outer chat route persists only the last metrics
# payload. Keep a copy so any approval produced after the
# recursive teacher run's metrics remains reloadable.
captured_metrics = dict(payload["data"])
if typ == "tool_output":
captured_tool_events.append({
captured_tool_event = {
"tool": payload.get("tool"),
"command": payload.get("command"),
"output": payload.get("output"),
"exit_code": payload.get("exit_code"),
})
}
if isinstance(payload.get("ask_user"), dict):
captured_tool_event["ask_user"] = payload["ask_user"]
captured_tool_events.append(captured_tool_event)
if "delta" in payload and isinstance(payload["delta"], str):
if payload.get("thinking"):
continue
@@ -697,6 +673,12 @@ async def run_teacher_inline(
continue
yield evt_str
# A takeover that paused for a question or exact action has not completed
# yet. Its server-owned approval card is already in the live/persisted tool
# events; do not evaluate the partial trace or distill it into a skill.
if any(event.get("ask_user") for event in captured_tool_events):
return
teacher_text = "".join(captured_text_parts).strip()
t_status, t_reason = evaluate_turn_regex(captured_tool_events, teacher_text)
if t_status == "failure":
@@ -740,31 +722,85 @@ async def run_teacher_inline(
skill.setdefault("source", "teacher-escalation")
skill.setdefault("teacher_model", teacher_spec)
import json as _json
from src.tool_implementations import do_manage_skills
try:
result = await do_manage_skills(_json.dumps(skill), owner=owner)
if isinstance(result, dict) and not result.get("error"):
logger.info(f"teacher succeeded; saved skill: {skill.get('name')}")
yield (
'data: ' + json.dumps({
"type": "skill_saved",
"name": skill.get("name"),
"category": skill.get("category", "general"),
}) + '\n\n'
)
else:
yield (
'data: ' + json.dumps({
"type": "skill_save_failed",
"reason": str(result),
}) + '\n\n'
)
except Exception as e:
logger.warning(f"skill save raised: {e}")
if not session_id:
yield (
'data: ' + json.dumps({
"type": "skill_save_failed",
"reason": str(e),
"reason": (
"Teacher-generated skills require an interactive exact "
"approval before they can be saved."
),
}) + '\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"
)
+35
View File
@@ -0,0 +1,35 @@
"""Shared wire values and scope markers for tool approval continuations."""
from __future__ import annotations
from enum import Enum
# Keep the existing wire values so the current route and no-build frontend do
# not need a second protocol migration. ``approve`` no longer means one action;
# it now selects chat-session scope.
TASK_APPROVAL_DECISION = "approve_task"
CHAT_SESSION_APPROVAL_DECISION = "approve"
DENY_APPROVAL_DECISION = "deny"
# Session.get_context_messages() adds this server-owned marker only when the
# session history contains a matching, resolved chat-session approval.
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
class ToolApprovalScope(str, Enum):
# Surfaces without a resumable chat (the skill tester, unattended audits)
# keep the original one-use meaning: the sealed action runs and the gate
# re-arms immediately for anything after it.
SINGLE_ACTION = "single_action"
TASK = "task"
CHAT_SESSION = "chat_session"
def scope_for_decision(decision: object) -> ToolApprovalScope | None:
normalized = str(decision or "").strip().lower()
if normalized == TASK_APPROVAL_DECISION:
return ToolApprovalScope.TASK
if normalized == CHAT_SESSION_APPROVAL_DECISION:
return ToolApprovalScope.CHAT_SESSION
return None
+513
View File
@@ -0,0 +1,513 @@
"""Opaque exact-action approvals with explicit task and chat scopes.
The server still seals and claims the first displayed action exactly once. The
selected scope then bypasses only the automatic post-external-context approval
gate for the rest of the resumed task or chat session. Browser-visible fields
are display copies, never authority.
"""
from __future__ import annotations
import hashlib
import json
import os
import secrets
import threading
import time
from dataclasses import dataclass, field
from typing import Any
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_DECISION,
DENY_APPROVAL_DECISION,
TASK_APPROVAL_DECISION,
ToolApprovalScope,
scope_for_decision,
)
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
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))
_MAX_APPROVAL_SELECTED_TOOLS = 512
_MAX_APPROVAL_TOOL_NAME_CHARS = 512
_MAX_APPROVAL_CONTINUATION_QUERY_CHARS = 4000
def _normalized_selected_tools(
selected_tools: Any,
*,
required_tool: Any = None,
) -> tuple[str, ...]:
if isinstance(selected_tools, str):
selected_tools = (selected_tools,)
try:
values = selected_tools or ()
names = {
name.strip()
for name in values
if (
isinstance(name, str)
and name.strip()
and len(name.strip()) <= _MAX_APPROVAL_TOOL_NAME_CHARS
)
}
required_name = str(required_tool or "").strip()
if required_name and len(required_name) <= _MAX_APPROVAL_TOOL_NAME_CHARS:
names.add(required_name)
ordered = sorted(names)
if len(ordered) <= _MAX_APPROVAL_SELECTED_TOOLS:
return tuple(ordered)
kept = ordered[:_MAX_APPROVAL_SELECTED_TOOLS]
if required_name and required_name in names and required_name not in kept:
kept[-1] = required_name
kept.sort()
return tuple(kept)
except TypeError:
return ()
def _normalized_continuation_query(value: Any) -> str:
# The query is server-derived from the interrupted run and already lives in
# session history. Keep the pending copy bounded because approvals are held
# in memory until consumed or expired.
return str(value or "").strip()[:_MAX_APPROVAL_CONTINUATION_QUERY_CHARS]
def _canonical_digest(payload: dict[str, Any]) -> str:
encoded = json.dumps(
payload,
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,
selected_tools: Any,
continuation_query: Any,
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),
"selected_tools": list(
_normalized_selected_tools(selected_tools, required_tool=tool_name)
),
"continuation_query": _normalized_continuation_query(continuation_query),
"effects": list(effects),
"result_integrity": str(result_integrity),
}
@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
# Server-only continuation state. Both fields are digest-bound and never
# exposed in the browser payload.
selected_tools: tuple[str, ...] = ()
continuation_query: str = ""
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
return {
"kind": "tool_approval",
"approval_id": self.approval_id,
# The browser already owns this chat id. Persisting it with the
# resolved card lets history-derived session grants remain bound to
# this exact chat and prevents inheritance by a forked session.
"session_id": self.session_id,
"question": "Allow this task to continue?",
"description": reason or (
"Untrusted context influenced this run, so continuing with "
"otherwise-gated actions needs your explicit approval."
),
"options": [
{
"label": "Allow for this task",
"value": TASK_APPROVAL_DECISION,
"description": (
"Execute the sealed action and allow every otherwise-gated "
"action needed to finish this request. Current tool, account, "
"workspace, and sandbox restrictions still apply."
),
},
{
"label": "Allow for this chat session",
"value": CHAT_SESSION_APPROVAL_DECISION,
"description": (
"Execute the sealed action and stop asking at this gate for "
"later requests in this chat. Current tool, account, workspace, "
"and sandbox restrictions still apply."
),
},
{
"label": "Deny",
"value": DENY_APPROVAL_DECISION,
"description": "Do not execute the proposed action.",
},
],
"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 exact first action plus an explicit continuation scope."""
pending: PendingToolApproval
scope: ToolApprovalScope = ToolApprovalScope.TASK
# The seam consumed by agent_loop. Both chat-card allow choices cover the
# complete resumed task, because one-action scope there immediately
# re-entered the same gate on the next round. Callers with no resumable
# chat still get SINGLE_ACTION, which leaves the gate armed behind the
# sealed action.
allow_remaining_actions: bool = True
_claimed: bool = field(default=False, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
@property
def grants_chat_session(self) -> bool:
return self.scope is ToolApprovalScope.CHAT_SESSION
def _matches_unlocked(
self,
*,
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
),
selected_tools=self.pending.selected_tools,
continuation_query=self.pending.continuation_query,
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,
selected_tools: Any = None,
continuation_query: 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,
selected_tools=selected_tools,
continuation_query=continuation_query,
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,
selected_tools=tuple(payload["selected_tools"]),
continuation_query=payload["continuation_query"],
)
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,
allow_continuation: bool = True,
) -> ExactToolApproval | None:
"""Consume a pending approval.
``allow_continuation`` is the caller's assertion that it owns a
resumable conversation the granted scope can apply to. Callers without
one (the skill tester, unattended audits) pass ``False`` and get the
original one-use grant, so a button labelled "Allow once" cannot widen
into a run-long bypass just because the chat card reuses the same wire
value.
"""
now = time.time()
with self._lock:
self._purge_expired_locked(now)
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)
normalized_decision = str(decision or "").strip().lower()
scope = scope_for_decision(normalized_decision)
if scope is None:
return None
if not allow_continuation:
return ExactToolApproval(
pending,
scope=ToolApprovalScope.SINGLE_ACTION,
allow_remaining_actions=False,
)
return ExactToolApproval(
pending,
scope=scope,
allow_remaining_actions=True,
)
def peek(self, approval_id: Any) -> PendingToolApproval | None:
now = time.time()
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()
+686
View File
@@ -0,0 +1,686 @@
"""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_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
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)
# Task-scope approval sets this for the resumed in-memory run. Chat-scope
# approval is projected from the server-owned session history marker below.
# The bypass affects only this automatic gate; current tool policy, ownership,
# workspace confinement, and execution/sandbox restrictions still apply.
approval_gate_bypassed: bool = False
def observe_messages(self, messages: Iterable[dict]) -> None:
"""Apply server-owned chat scope and promote untrusted prompt context."""
message_list = list(messages or ())
if any(
isinstance(message, dict)
and isinstance(message.get("metadata"), dict)
and message["metadata"].get(
CHAT_SESSION_APPROVAL_CONTEXT_MARKER
) is True
for message in message_list
):
self.approval_gate_bypassed = True
if messages_contain_external_untrusted_context(message_list):
self.external_untrusted_context_seen = True
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
if self.approval_gate_bypassed:
return ToolGateDecision(True)
if not self.external_untrusted_context_seen:
return ToolGateDecision(True)
capabilities = capabilities_for_action(tool_name, content)
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
View File
@@ -27,10 +27,24 @@ from src.tool_security import (
is_public_blocked_tool,
owner_is_admin_or_single_user,
)
from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result
from src.tool_approvals import ExactToolApproval
from src.tool_policy import ToolPolicy
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
from src.tool_utils import _truncate, get_mcp_manager
class _MissingToolSecurityContext:
pass
class _NoToolSecurityContext:
"""Explicit sentinel for non-agent callers that have no run provenance."""
_MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
# Persistent working directory for agent subprocesses.
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
# (/app/data) and the local data directory for manual installs.
@@ -554,10 +568,19 @@ async def _document_tool_dispatch(
content: str,
session_id: Optional[str] = None,
owner: Optional[str] = None,
document_id: Optional[str] = None,
document_version: Optional[int] = None,
document_digest: Optional[str] = None,
) -> Optional[Dict]:
"""Route a document tool through TOOL_HANDLERS with the right ctx shape."""
from src.agent_tools import TOOL_HANDLERS
ctx = {"session_id": session_id, "owner": owner}
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:
return await TOOL_HANDLERS[tool](content, ctx)
return None
@@ -575,6 +598,12 @@ async def execute_tool_block(
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
tool_policy: Optional[Any] = None,
security_context: (
ToolRunSecurityContext
| _NoToolSecurityContext
| _MissingToolSecurityContext
) = _MISSING_TOOL_SECURITY_CONTEXT,
exact_approval: Optional[ExactToolApproval] = None,
) -> Tuple[str, Dict]:
"""Execute a single tool block. Returns (description, result_dict).
@@ -582,6 +611,104 @@ async def execute_tool_block(
cwd confine to it) for the duration of this call, then delegate. Reset on the
way out so the binding never leaks to the next tool call.
"""
if security_context is _MISSING_TOOL_SECURITY_CONTEXT:
raise TypeError(
"execute_tool_block requires security_context; pass a "
"ToolRunSecurityContext or NO_TOOL_SECURITY_CONTEXT explicitly"
)
if (
not isinstance(security_context, ToolRunSecurityContext)
and security_context is not NO_TOOL_SECURITY_CONTEXT
):
raise TypeError(
"security_context must be a ToolRunSecurityContext or "
"NO_TOOL_SECURITY_CONTEXT"
)
approval_claimed = False
if exact_approval is not None:
if (
not isinstance(security_context, ToolRunSecurityContext)
or not security_context.external_untrusted_context_seen
or not exact_approval.pending.external_untrusted_context_seen
):
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": "Exact-action approval requires an armed run security context.",
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
if (
exact_approval.pending.tool_name
in {"edit_document", "suggest_document", "update_document"}
and (
not exact_approval.pending.document_id
or exact_approval.pending.document_version is None
or not exact_approval.pending.document_digest
)
):
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": (
"The approved document action has no sealed target and "
"cannot be executed."
),
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
sealed_workspace = exact_approval.pending.workspace
if sealed_workspace and vet_workspace(sealed_workspace) != sealed_workspace:
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": (
"The approved workspace is no longer a valid safe "
"directory. Review the action again."
),
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
approval_claimed = exact_approval.claim(
owner=owner,
session_id=session_id,
tool_name=getattr(block, "tool_type", None),
content=getattr(block, "content", None),
workspace=workspace,
)
if not approval_claimed:
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": "The exact-action approval did not match this tool request.",
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed:
decision = security_context.decision_for(
getattr(block, "tool_type", None),
getattr(block, "content", None),
)
if not decision.allowed:
logger.warning(
"External-context policy blocked tool=%r",
getattr(block, "tool_type", None),
)
return blocked_tool_result(
getattr(block, "tool_type", None),
decision.reason or "Tool blocked by external-context policy.",
)
token = _active_workspace.set(workspace or None)
try:
output = await _execute_tool_block_impl(
@@ -591,7 +718,28 @@ async def execute_tool_block(
owner=owner,
progress_cb=progress_cb,
tool_policy=tool_policy,
approved_document_id=(
exact_approval.pending.document_id
if approval_claimed
else None
),
approved_document_version=(
exact_approval.pending.document_version
if approval_claimed
else None
),
approved_document_digest=(
exact_approval.pending.document_digest
if approval_claimed
else None
),
)
if isinstance(security_context, ToolRunSecurityContext):
security_context.observe_tool_result(
getattr(block, "tool_type", None),
output[1],
getattr(block, "content", None),
)
return output
finally:
_active_workspace.reset(token)
@@ -604,6 +752,9 @@ async def _execute_tool_block_impl(
owner: Optional[str] = None,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
tool_policy: Optional[Any] = None,
approved_document_id: Optional[str] = None,
approved_document_version: Optional[int] = None,
approved_document_digest: Optional[str] = None,
) -> Tuple[str, Dict]:
"""Execute a single tool block. Returns (description, result_dict).
@@ -765,7 +916,15 @@ async def _execute_tool_block_impl(
elif tool in ("create_document", "update_document", "edit_document",
"suggest_document", "manage_documents"):
desc = f"{tool}: {content.split(chr(10))[0][:80]}"
result = await _document_tool_dispatch(tool, content, session_id, owner) \
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}
if tool in ("edit_document", "suggest_document") and "title" in (result or {}):
desc = f"{tool}: {result.get('title', '')}"
+10 -2
View File
@@ -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",
json={"command": cmd}, headers=headers)
if resp.status_code >= 400:
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
return {
"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}",
"exit_code": 1,
"untrusted_content": True,
}
try:
data = resp.json()
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",
json={"command": cmd}, headers=headers)
if resp.status_code >= 400:
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
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 {}
output_text = (data.get("stdout") or "").strip()
stderr_text = (data.get("stderr") or "").strip()
+5 -1
View File
@@ -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",
json=payload, headers=_internal_headers(owner))
if resp.status_code >= 400:
return {"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
return {
"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}",
"exit_code": 1,
"untrusted_content": True,
}
data = resp.json()
sid = data.get("session_id", "?")
return {
+1
View File
@@ -725,6 +725,7 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
"status_code": resp.status_code,
"body": preview,
"exit_code": 1,
"untrusted_content": True,
}
return {
"output": f"{method} {path} -> {resp.status_code}\n{preview}",
+31 -20
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import re
from contextvars import ContextVar
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone, tzinfo
from typing import Dict, Optional
@@ -65,19 +65,31 @@ def format_utc_offset(offset_min: Optional[int]) -> str:
return f"{sign}{hours:02d}:{minutes:02d}"
def user_timezone() -> timezone:
"""Return the best known user timezone as a fixed-offset tzinfo."""
def _zoneinfo_from_name():
"""Return ZoneInfo for the request's IANA name, or None if missing/invalid."""
name = get_user_tz_name()
if not name:
return None
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name)
except Exception:
return None
def user_timezone() -> tzinfo:
"""Return the best known user timezone.
A valid IANA name wins over x-tz-offset. The offset is a fixed number and
can disagree with the name (wrong sign, stale client); the name carries DST.
"""
zone = _zoneinfo_from_name()
if zone is not None:
return zone
offset = get_user_tz_offset()
if offset is None:
name = get_user_tz_name()
if name:
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name)
except Exception:
pass
return datetime.now().astimezone().tzinfo or timezone.utc
return timezone(timedelta(minutes=offset))
if offset is not None:
return timezone(timedelta(minutes=offset))
return datetime.now().astimezone().tzinfo or timezone.utc
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
@@ -100,14 +112,13 @@ def _clock_label(dt: datetime) -> str:
def timezone_label(dt: Optional[datetime] = None) -> str:
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
offset = get_user_tz_offset()
if offset is None:
if dt is None:
dt = datetime.now().astimezone()
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
if dt is None:
dt = now_user_local()
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
offset_label = f"UTC{format_utc_offset(offset)}"
name = get_user_tz_name()
return f"{name}, {offset_label}" if name else offset_label
if _zoneinfo_from_name() is not None:
return f"{get_user_tz_name()}, {offset_label}"
return offset_label
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
+4
View File
@@ -34,6 +34,10 @@ fi
# values (APP_PORT / APP_BIND), then built-in defaults.
PORT="${ODYSSEUS_PORT:-${APP_PORT:-7860}}" # 7860, not 7000 — macOS AirPlay Receiver holds 7000.
HOST="${ODYSSEUS_HOST:-${APP_BIND:-127.0.0.1}}" # Set APP_BIND=0.0.0.0 in .env for LAN/Tailscale access.
# The port only reaches uvicorn as a flag, so export it too: everything that
# builds a URL for this instance — internal_api_base(), the companion pairing
# code, the MCP OAuth callback — reads APP_PORT and would otherwise assume 7000.
export APP_PORT="$PORT"
PROBE_HOST="$HOST"
if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then
PROBE_HOST="127.0.0.1"
+12 -13
View File
@@ -10,9 +10,9 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
import chatModule from './js/chat.js?v=20260801fix1';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import chatModule from './js/chat.js?v=20260819approvalcontrol1';
import compareModule from './js/compare/index.js?v=20260819approvalcontrol1';
import documentModule from './js/document.js?v=20260815approvalsave1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import {
@@ -22,7 +22,7 @@ import {
settleSessionHydration
} from './js/startupShell.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
import chatRenderer from './js/chatRenderer.js?v=20260819approvalcontrol1';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
@@ -33,7 +33,7 @@ import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
import calendarModule from './js/calendar.js';
import notesModule from './js/notes.js';
import adminModule from './js/admin.js?v=20260716openrouter3';
import settingsModule from './js/settings.js?v=20260722emailfastindex1';
import settingsModule from './js/settings.js?v=20260815approvalsave1';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js?v=20260723compareicon2';
// Desktop window tiling — drag a modal near an edge/corner to snap.
@@ -50,6 +50,7 @@ import * as researchPanelModule from './js/research/panel.js?v=20260630researcht
import ttsModule from './js/tts-ai.js';
import spinnerModule from './js/spinner.js';
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
import { getSettings } from './js/appConfig.js';
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js?v=20260715startupclean';
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
@@ -1518,13 +1519,11 @@ function initializeEventListeners() {
})
.catch(() => {});
// Hide Gallery when image generation is disabled in settings
const _prefetchedSettings = sessionStorage.getItem('ody-prefetch-settings');
sessionStorage.removeItem('ody-prefetch-settings');
window._initSettingsReady = (_prefetchedSettings
? Promise.resolve(JSON.parse(_prefetchedSettings))
: fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' }).then(r => r.json())
).then(settings => {
// Hide Gallery when image generation is disabled in settings.
// getSettings() consumes the login prefetch itself, so every other module
// that asks for settings this load gets the same snapshot without a request.
window._initSettingsReady = getSettings()
.then(settings => {
// NOTE: image_gen_enabled only governs *generating* images in chat — the
// tool is blocked server-side (chat_routes / agent_loop). The Gallery
// holds uploads and past images too, so it stays visible regardless;
@@ -3705,7 +3704,7 @@ function startOdysseusApp() {
modelsModule.init(API_BASE);
ragModule.init(API_BASE);
presetsModule.init(API_BASE);
searchModule.init(API_BASE);
searchModule.init();
chatModule.init(API_BASE);
chatModule.initListeners();
groupModule.init(API_BASE);
+64 -26
View File
@@ -231,23 +231,11 @@
}
}
</style>
<!-- KaTeX CSS is loaded with media="print" so it doesn't block render,
then flipped to "all" via JS after load. Mermaid init runs once the
library finishes loading. Both hooks are wired via addEventListener
below (inline onload= attrs are blocked by CSP script-src-attr). -->
<link id="katex-css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" media="print">
<script async src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js"></script>
<script id="mermaid-script" async src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script nonce="{{CSP_NONCE}}">
(function(){
var k = document.getElementById('katex-css');
if (k) k.addEventListener('load', function(){ k.media = 'all'; }, { once: true });
var m = document.getElementById('mermaid-script');
if (m) m.addEventListener('load', function(){
if (window.odysseusInitMermaid) window.odysseusInitMermaid();
}, { once: true });
})();
</script>
<!-- KaTeX and Mermaid are vendored in /static/lib and pulled in by
static/js/markdown.js the first time a page actually renders math or a
```mermaid fence. They used to load here from cdn.jsdelivr.net on every
page load, which cost ~985 KB on the wire, broke offline installs, and
announced every session to a third party. -->
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
the app font and the weight the sidebar and header text render at. They
are declared in style.css, so without a hint they are only discovered
@@ -258,8 +246,8 @@
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
<link rel="modulepreload" href="/static/app.js?v=20260808startupshell1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
<link rel="modulepreload" href="/static/app.js?v=20260815toolapproval4">
<link rel="modulepreload" href="/static/js/chat.js?v=20260815toolapproval4">
<link rel="modulepreload" href="/static/js/ui.js">
<link rel="modulepreload" href="/static/js/sessions.js">
<link rel="modulepreload" href="/static/js/markdown.js">
@@ -1421,6 +1409,55 @@
</div>
<div class="settings-layout">
<div class="settings-sidebar">
<button
type="button"
class="settings-sidebar-toggle"
id="settings-sidebar-toggle"
aria-label="Collapse settings navigation"
title="Collapse settings navigation"
>
<svg class="settings-sidebar-toggle-collapse" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
<svg class="settings-sidebar-toggle-expand" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div
class="settings-sidebar-resize-handle"
id="settings-sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize settings navigation"
aria-valuemin="150"
aria-valuemax="340"
aria-valuenow="220"
tabindex="0"
></div>
<div class="settings-sidebar-content">
<div class="settings-nav-search-wrap">
<svg class="settings-nav-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="M20 20l-4-4"></path>
</svg>
<input
type="search"
id="settings-nav-search"
class="settings-nav-search"
placeholder="Find settings…"
autocomplete="off"
aria-label="Find settings"
aria-controls="settings-nav-search-results"
/>
<div
id="settings-nav-search-results"
class="settings-nav-search-results hidden"
role="listbox"
aria-label="Settings search results"
></div>
</div>
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
<button class="settings-nav-item active" data-settings-tab="services">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
@@ -1481,9 +1518,10 @@
<span>Users</span>
</button>
<button class="settings-nav-item admin-only" data-settings-tab="system">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-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>
</button>
</div>
</div>
<div class="settings-panels">
@@ -2532,20 +2570,20 @@
<script type="module" src="/static/js/search.js"></script>
<script type="module" src="/static/js/spinner.js"></script>
<script type="module" src="/static/js/tts-ai.js"></script>
<script type="module" src="/static/js/document.js?v=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/chatRenderer.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/chatRenderer.js?v=20260819approvalcontrol1"></script>
<script type="module" src="/static/js/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
<script type="module" src="/static/js/chatStream.js?v=20260819approvalcontrol1"></script>
<script type="module" src="/static/js/chat.js?v=20260819approvalcontrol1"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/theme.js"></script>
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=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/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/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
+65 -17
View File
@@ -6,6 +6,7 @@ import settingsModule from './settings.js';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js';
let initialized = false;
let modalEl = null;
@@ -345,8 +346,7 @@ function initSignupToggle() {
function initShareDefaultsToggle() {
const toggle = el('adm-shareDefaultsToggle');
fetch('/api/auth/settings', { credentials: 'same-origin' })
.then(r => r.json())
getSettings()
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
.catch(e => console.warn('Settings fetch failed:', e));
toggle.addEventListener('change', async () => {
@@ -361,6 +361,9 @@ function initShareDefaultsToggle() {
toggle.checked = !!data.share_defaults_with_users;
} catch (e) {
toggle.checked = !toggle.checked;
} finally {
// Drop the shared snapshot: it still says what this toggle used to be.
invalidateSettings();
}
});
}
@@ -1893,8 +1896,16 @@ async function loadBuiltinTools() {
const list = el('adm-builtin-tools-list');
if (!list) return;
try {
const res = await fetch('/api/tools', { credentials: 'same-origin' });
const data = await res.json();
// This panel is an editor, and its save posts the whole disabled list
// rebuilt from the checkboxes below. So it has to render authoritative
// state: a snapshot that went stale out of band (the manage_settings tool,
// another tab) would be re-posted wholesale on the next unrelated toggle
// and would silently undo the newer state. refreshAll() calls this on every
// panel open, so drop the shared entry and refill it. The startup read that
// chatRenderer.js shares is unaffected; this panel just never edits a cache,
// which is the same rule the settings panel follows by reading directly.
invalidateTools();
const data = await getTools();
const tools = data.tools || [];
if (!tools.length) { list.innerHTML = '<div class="admin-empty">No tools found</div>'; return; }
@@ -1968,17 +1979,50 @@ async function loadBuiltinTools() {
});
});
// Helper: save disabled tools + update counters
async function _saveToolState() {
const allChecks = list.querySelectorAll('input[data-tool-id]');
const disabled = [];
allChecks.forEach(c => { if (!c.checked) disabled.push(c.dataset.toolId); });
await fetch('/api/tools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled }),
credentials: 'same-origin',
});
// Merge only the user's intended changes onto authoritative server state.
// /api/tools replaces the full disabled list, so rebuilding it from this
// panel's DOM can undo a change made by another tab or manage_settings
// after the panel was opened.
async function _saveToolState(changes) {
invalidateTools();
const latest = await getTools();
const state = new Map(
(latest.tools || []).map(t => [t.id, !!t.enabled])
);
for (const change of changes) {
if (state.has(change.id)) {
state.set(change.id, !!change.enabled);
}
}
const disabled = Array.from(state.entries())
.filter(([, enabled]) => !enabled)
.map(([id]) => id);
try {
const res = await fetch('/api/tools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled }),
credentials: 'same-origin',
});
if (!res.ok) throw new Error(`Failed to update tools (${res.status})`);
// Bring the still-open editor forward to the same merged snapshot so an
// out-of-band change is visible instead of leaving stale checkboxes.
list.querySelectorAll('input[data-tool-id]').forEach(c => {
if (state.has(c.dataset.toolId)) {
c.checked = state.get(c.dataset.toolId);
}
});
list.querySelectorAll('.admin-tool-category').forEach(_updateCatCounter);
} finally {
// This route persists disabled_tools into the settings store
// (routes/model_routes.py), so both snapshots are now stale.
invalidateTools();
invalidateSettings();
}
}
function _updateCatCounter(catEl) {
if (!catEl) return;
@@ -1993,7 +2037,9 @@ async function loadBuiltinTools() {
// Wire individual tool toggles
list.querySelectorAll('input[data-tool-id]').forEach(chk => {
chk.addEventListener('change', async () => {
await _saveToolState();
await _saveToolState([
{ id: chk.dataset.toolId, enabled: chk.checked },
]);
_updateCatCounter(chk.closest('.admin-tool-category'));
});
});
@@ -2004,8 +2050,10 @@ async function loadBuiltinTools() {
const catEl = chk.closest('.admin-tool-category');
if (!catEl) return;
const checked = chk.checked;
const changes = Array.from(catEl.querySelectorAll('input[data-tool-id]'))
.map(c => ({ id: c.dataset.toolId, enabled: checked }));
catEl.querySelectorAll('input[data-tool-id]').forEach(c => { c.checked = checked; });
await _saveToolState();
await _saveToolState(changes);
_updateCatCounter(catEl);
});
});
+86
View File
@@ -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;
}
+135 -81
View File
@@ -8,18 +8,18 @@
import Storage from './storage.js';
import uiModule from './ui.js';
import sessionModule from './sessions.js';
import chatRenderer from './chatRenderer.js?v=20260722emailfastindex1';
import chatStream from './chatStream.js';
import chatRenderer from './chatRenderer.js?v=20260819approvalcontrol1';
import chatStream from './chatStream.js?v=20260819approvalcontrol1';
import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js';
import spinnerModule from './spinner.js';
import presetsModule from './presets.js';
import fileHandlerModule from './fileHandler.js';
import searchModule from './search.js';
import documentModule from './document.js?v=20260722emailfastindex1';
import * as emailInbox from './emailInbox.js?v=20260722emailfastindex1';
import documentModule from './document.js?v=20260815approvalsave1';
import * as emailInbox from './emailInbox.js?v=20260815approvalsave1';
import codeRunnerModule from './codeRunner.js';
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260722emailfastindex1';
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260815approvalsave1';
import createResearchSynapse from './researchSynapse.js';
import { createStreamRenderer } from './streamingRenderer.js';
import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall';
@@ -35,6 +35,7 @@ import {
inheritModelRouteState,
} from './chatModelProvenance.js';
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
import { loadPanel } from './panels.js';
const RESEARCH_TIMEOUT_MS = 360000;
const DEFAULT_TIMEOUT_MS = 120000;
@@ -59,6 +60,36 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
let _contextHeaderSeq = 0;
let _contextHeaderData = null;
let _contextHeaderBound = false;
let _pendingToolApproval = null;
function _submitToolApprovalWhenIdle(approvalId) {
if (
!_pendingToolApproval
|| _pendingToolApproval.approval_id !== approvalId
) return;
if (isStreaming || _sendInFlight) {
setTimeout(() => _submitToolApprovalWhenIdle(approvalId), 120);
return;
}
const input = document.getElementById('message');
if (input) {
_pendingToolApproval.draft = input.value || '';
}
const sendButton = document.querySelector('.send-btn');
if (sendButton) sendButton.click();
}
document.addEventListener('odysseus:tool-approval', (event) => {
const detail = event && event.detail ? event.detail : {};
const decision = String(detail.decision || '').toLowerCase();
if (!detail.approval_id || !['approve', 'approve_task', 'deny'].includes(decision)) return;
_pendingToolApproval = {
approval_id: String(detail.approval_id),
decision,
document_id: String(detail.document_id || ''),
};
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
});
function _fmtContextNumber(n) {
const v = Number(n || 0);
@@ -1234,6 +1265,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (_sendInFlight) return;
const _sendPerf = _createChatSendPerf();
_sendInFlight = true;
const approvalForSend = _pendingToolApproval;
_setForegroundChatBusy(true);
// Instant visual feedback so the user sees their click was accepted
// even before the streaming button state kicks in below.
@@ -1248,7 +1280,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
};
// --- Setup mode: intercept next message (but let slash commands through) ---
{
if (!approvalForSend) {
const el = uiModule.el;
const rawMsg = (el('message').value || '').trim();
const currentSetupMode = slashCommands.getSetupMode();
@@ -1272,13 +1304,13 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
}
const el = uiModule.el;
const msg = el('message').value;
const msg = approvalForSend ? '' : el('message').value;
// Allow empty text when a regen carries over the original message's
// attachment ids — a photo-only message still has something to send.
if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
if (!msg.trim() && !approvalForSend && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
// --- Slash commands: execute directly without AI (no session needed) ---
if (isCommand(msg.trim())) {
if (!approvalForSend && isCommand(msg.trim())) {
const handled = await handleSlashCommand(msg.trim());
if (handled) {
el('message').value = '';
@@ -1405,7 +1437,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
}
// --- API key guard: warn if message looks like an API key ---
if (API_KEY_RE.test(msg.trim())) {
if (!approvalForSend && API_KEY_RE.test(msg.trim())) {
if (!await window.styledConfirm('This looks like an API key. Sending it to the AI could expose it.\n\nDid you mean to use /setup instead?', { confirmText: 'Send anyway', danger: true })) {
_releaseSendFlag();
return;
@@ -1540,7 +1572,9 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (sessionModule.clearStreamComplete) sessionModule.clearStreamComplete(sessionModule.getCurrentSessionId());
// Check for document selection context before consuming display override
const docSel = documentModule && documentModule.getSelectionContext();
const docSel = !approvalForSend && documentModule
? documentModule.getSelectionContext()
: null;
if (docSel) {
const sels = Array.isArray(docSel) ? docSel : [docSel];
const lineRefs = sels.map(s =>
@@ -1551,7 +1585,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
const userDisplay = _displayOverride || msg;
_displayOverride = null;
const skipBubble = _hideUserBubble;
const skipBubble = _hideUserBubble || !!approvalForSend;
_hideUserBubble = false;
// Auto-recovery counter: carries across a turn's auto-continues, but resets
// when the user genuinely sends a new message (so each task gets a fresh cap).
@@ -1560,7 +1594,9 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
// stuck flag can't silently eat the next turn's recovery budget.
if (!skipBubble) { _autoNudges = 0; _autoContinuePending = false; }
else if (_autoContinuePending) { _autoContinuePending = false; }
const _pendingAttachInfo = fileHandlerModule.getPendingCount() ? fileHandlerModule.getPendingInfo() : null;
const _pendingAttachInfo = !approvalForSend && fileHandlerModule.getPendingCount()
? fileHandlerModule.getPendingInfo()
: null;
// Pre-read importable file contents before upload clears pending files
const IMPORTABLE_EXT = /\.(txt|py|js|ts|html|htm|css|md|json|csv|yml|yaml|sh|sql|rs|go|java|c|cpp|h|rb|php|xml|jsx|tsx|log|toml|ini|conf|env|vue|svelte|scss|sass|less)$/i;
const _importableFiles = [];
@@ -1578,7 +1614,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
_userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null);
}
_sendPerf.mark('user_bubble_visible');
messageInput.value = '';
messageInput.value = approvalForSend ? (approvalForSend.draft || '') : '';
messageInput.style.height = '';
messageInput.dispatchEvent(new Event('input'));
// Mobile: dismiss the on-screen keyboard after sending. iOS in
@@ -1612,13 +1648,15 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
}
let ids = [];
try {
_sendPerf.mark('upload_begin');
ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() });
_sendPerf.mark('upload_done');
} catch(e) {
console.error('upload failed', e);
_sendPerf.mark('upload_failed');
if (!approvalForSend) {
try {
_sendPerf.mark('upload_begin');
ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() });
_sendPerf.mark('upload_done');
} catch(e) {
console.error('upload failed', e);
_sendPerf.mark('upload_failed');
}
}
if (_pendingAttachInfo && !ids.length && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) {
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
@@ -1635,10 +1673,10 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
// edited OCR text via the server-side .vision cache). Always CONSUME the
// slot — even when empty / errored — so the regen ids can't bleed into
// an unrelated next message if uploadPending() above had thrown.
if (_pendingRegenAttachments && _pendingRegenAttachments.length) {
if (!approvalForSend && _pendingRegenAttachments && _pendingRegenAttachments.length) {
ids = ids.concat(_pendingRegenAttachments);
}
_pendingRegenAttachments = null;
if (!approvalForSend) _pendingRegenAttachments = null;
// The optimistic user bubble was rendered before the upload assigned ids,
// so image previews couldn't show (the renderer needs att.id). Now that
@@ -1719,14 +1757,50 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (activeEmailComposerCtx?.docId) {
activeDocIdForSend = activeEmailComposerCtx.docId;
}
if (documentModule && activeDocIdForSend) {
const shouldSaveActiveDoc = !approvalForSend || (
approvalForSend.document_id
&& approvalForSend.document_id === activeDocIdForSend
);
if (documentModule && activeDocIdForSend && shouldSaveActiveDoc) {
try {
_sendPerf.mark('doc_save_begin');
await documentModule.saveDocument();
const documentSaved = await documentModule.saveDocument({
silent: !!approvalForSend,
});
_sendPerf.mark('doc_save_done');
if (approvalForSend && documentSaved === false) {
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
if (
_pendingToolApproval
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
) {
_pendingToolApproval = null;
}
uiModule.showError && uiModule.showError(
'Document could not be saved, so the action was not approved. Reload the chat to retry.'
);
updateSubmitButton('idle', submitBtn);
_releaseSendFlag();
return;
}
} catch(e) {
console.warn('doc auto-save failed', e);
_sendPerf.mark('doc_save_failed');
if (approvalForSend) {
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
if (
_pendingToolApproval
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
) {
_pendingToolApproval = null;
}
uiModule.showError && uiModule.showError(
'Document could not be saved, so the action was not approved. Reload the chat to retry.'
);
updateSubmitButton('idle', submitBtn);
_releaseSendFlag();
return;
}
}
}
@@ -1754,20 +1828,32 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
const fd = new FormData();
fd.append('message', _finalMsgWithInject);
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
fd.append('session', streamSessionId);
if (approvalForSend) {
fd.append('tool_approval_id', approvalForSend.approval_id);
fd.append('tool_approval_decision', approvalForSend.decision);
if (
_pendingToolApproval
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
) {
_pendingToolApproval = null;
}
}
if (selectedRouteForSend.model) fd.append('selected_model', selectedRouteForSend.model);
if (selectedRouteForSend.endpoint_url) fd.append('selected_endpoint_url', selectedRouteForSend.endpoint_url);
if (selectedRouteForSend.endpoint_id) fd.append('selected_endpoint_id', selectedRouteForSend.endpoint_id);
if (ids.length) fd.append('attachments', JSON.stringify(ids));
// Auto-save & send active doc ID so the backend sees latest content
if (documentModule && activeDocIdForSend) {
try {
_sendPerf.mark('doc_silent_save_begin');
await documentModule.saveDocument({ silent: true });
_sendPerf.mark('doc_silent_save_done');
} catch (_e) {
_sendPerf.mark('doc_silent_save_failed');
if (documentModule && activeDocIdForSend && shouldSaveActiveDoc) {
if (!approvalForSend) {
try {
_sendPerf.mark('doc_silent_save_begin');
await documentModule.saveDocument({ silent: true });
_sendPerf.mark('doc_silent_save_done');
} catch (_e) {
_sendPerf.mark('doc_silent_save_failed');
}
}
fd.append('active_doc_id', activeDocIdForSend);
}
@@ -1821,7 +1907,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (isAgentMode) {
fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false');
}
if (el('research-toggle').checked) {
if (!approvalForSend && el('research-toggle').checked) {
fd.append('use_research', 'true');
// Research always runs in chat mode — override agent if set
fd.set('mode', 'chat');
@@ -2152,9 +2238,6 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
_roundDisplayProjector.reset();
_replyDisplayProjector.reset();
_docFenceOpened = false;
_docFenceContentStart = -1;
_docFenceCandidateStart = -1;
_docFenceCandidateMarker = '';
}
const esc = uiModule.esc;
// Remove thinking spinner helper
@@ -2244,9 +2327,6 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
// Document streaming state (text-fence detection)
let _docFenceOpened = false;
let _docFenceContentStart = -1;
let _docFenceCandidateStart = -1;
let _docFenceCandidateMarker = '';
const _thinkingAnalysisGate = createThinkingAnalysisGate({
startsWithReasoningPrefix: markdownModule.startsWithReasoningPrefix,
});
@@ -2788,7 +2868,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (spinner && spinner.element) spinner.destroy();
break;
}
if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
if (json.delta || json.type === 'agent_prep' || json.type === 'tool_approval_resolved' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
@@ -2805,6 +2885,14 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
}
continue;
}
if (json.type === 'tool_approval_resolved') {
_cancelThinkingTimer();
_removeThinkingSpinner();
if (spinner && spinner.element) spinner.destroy();
if (!_isBg && roundHolder && roundHolder !== holder) roundHolder.remove();
if (!_isBg && holder) holder.remove();
continue;
}
if (json.delta) {
_cancelThinkingTimer();
_removeThinkingSpinner();
@@ -2841,42 +2929,11 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
roundText += _delta;
_roundDisplayProjector.append(_delta, roundText);
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
if (!_docFenceOpened && documentModule) {
// Only inspect the newly appended boundary. Re-scanning the
// full round for every reasoning delta is quadratic even
// before thinking normalization runs.
const fenceMarkers = ['```document\n', '```documen\n', '```create_document\n'];
const fenceScanStart = Math.max(0, roundText.length - _delta.length - 24);
if (_docFenceCandidateStart < 0) {
for (const candidate of fenceMarkers) {
const candidateIdx = roundText.indexOf(candidate, fenceScanStart);
if (candidateIdx >= 0 && (_docFenceCandidateStart < 0 || candidateIdx < _docFenceCandidateStart)) {
_docFenceCandidateMarker = candidate;
_docFenceCandidateStart = candidateIdx;
}
}
}
if (_docFenceCandidateStart >= 0) {
const afterFence = roundText.slice(_docFenceCandidateStart + _docFenceCandidateMarker.length);
const fenceLines = afterFence.split('\n');
if (fenceLines.length >= 1 && fenceLines[0].trim()) {
_docFenceOpened = true;
const title = fenceLines[0].trim();
// Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py
const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini'];
const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase());
const lang = isLang ? fenceLines[1].trim() : '';
_docFenceContentStart = _docFenceCandidateStart + _docFenceCandidateMarker.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0);
documentModule.streamDocOpen(title, lang);
}
}
}
if (_docFenceOpened && _docFenceContentStart > 0 && documentModule) {
let raw = roundText.slice(_docFenceContentStart);
const closeIdx = raw.indexOf('\n```');
if (closeIdx >= 0) raw = raw.slice(0, closeIdx);
documentModule.streamDocDelta(raw);
// Raw model text is not authorization to mutate the editor.
// Detect document fences only for chat projection/status; the
// server emits doc_stream_* after successful dispatch.
if (!_docFenceOpened) {
_docFenceOpened = /```(?:create_document|documen(?:t)?)\s*\n/i.test(roundText);
}
// Detect thinking-in-progress:
@@ -3796,9 +3853,6 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
_roundDisplayProjector.reset();
_replyDisplayProjector.reset();
_docFenceOpened = false;
_docFenceContentStart = -1;
_docFenceCandidateStart = -1;
_docFenceCandidateMarker = '';
const box = document.getElementById('chat-history');
const newWrap = document.createElement('div');
newWrap.className = 'msg msg-ai msg-continuation streaming';
@@ -6556,7 +6610,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
// Images → Gallery editor.
if (isImage) {
try {
const gx = await import('./galleryEditor.js');
const gx = await loadPanel('editor');
if (gx.openEditor) { gx.openEditor(url, id, null, name); return; }
} catch (e) { console.warn('gallery open failed', e); }
window.open(url, '_blank');
+122 -15
View File
@@ -9,7 +9,9 @@ import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js';
import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js';
import { loadPanel } from './panels.js';
import { matchModelKey } from './model/matchKey.js';
import { getTools } from './appConfig.js';
const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
@@ -445,8 +447,12 @@ function stripExecutedFence(match, tag, inline, body) {
async function loadExecFenceRegex() {
try {
const res = await fetch('/api/tools', { credentials: 'same-origin' });
const data = await res.json();
// Shared with admin.js, and — more to the point — with the other copies of
// this module: chatRenderer.js is imported under three different ?v= query
// strings, so it is instantiated three times per load and used to issue
// three identical /api/tools requests. appConfig.js is imported by one
// specifier from all of them, so they now share a single fetch.
const data = await getTools();
const tags = (data.tools || [])
.map((t) => t.id)
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
@@ -1367,7 +1373,7 @@ document.addEventListener('click', function(e) {
} catch {}
});
} else if (kind === 'document') {
import('./document.js?v=20260722emailfastindex1').then(mod => {
import('./document.js?v=20260815approvalsave1').then(mod => {
const open = mod.loadDocument
|| mod.openDocument
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
@@ -1389,7 +1395,7 @@ document.addEventListener('click', function(e) {
if (open) open(id);
}).catch(() => {});
} 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);
if (open) open({ uid: id });
}).catch(() => {});
@@ -1548,7 +1554,7 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
try {
const [galleryMod, editorMod] = await Promise.all([
import('./gallery.js'),
import('./galleryEditor.js'),
loadPanel('editor'),
]);
// Ensure the Gallery modal is open so the editor has a container
// to render into; switch its tabs to the Edit tab.
@@ -2321,6 +2327,42 @@ export function removeAskUserCards(root) {
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
}
// While a choice card is visible, let plain 13 activate the corresponding
// rendered option. Reuse the option's click path so the question keeps its
// existing submission semantics. Tool approval cards are excluded: that card
// exists to make consent deliberate after untrusted context influenced the
// run, and its first option is the widest grant, so a stray digit must not
// answer it.
function _handleAskUserShortcut(event) {
if (
event.defaultPrevented
|| event.repeat
|| event.isComposing
|| event.ctrlKey
|| event.altKey
|| event.metaKey
|| event.shiftKey
) return;
if (!/^[1-3]$/.test(event.key)) return;
const target = event.target;
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
const focusedCard = document.activeElement?.closest?.('.ask-user-card') || null;
const mainCard = document.querySelector('#chat-history .ask-user-card');
const compareCards = document.querySelectorAll('.compare-pane .ask-user-card');
const card = focusedCard || mainCard || (compareCards.length === 1 ? compareCards[0] : null);
if (!card) return;
if (card.dataset.askUserKind === 'tool_approval') return;
const option = card.querySelectorAll('.ask-user-option')[Number(event.key) - 1];
if (!option || option.disabled) return;
event.preventDefault();
option.click();
}
document.addEventListener('keydown', _handleAskUserShortcut);
/**
* Render an ask_user payload as a durable choice card.
*
@@ -2330,11 +2372,15 @@ export function removeAskUserCards(root) {
*/
export function renderAskUserCard(payload, options) {
const aq = payload || {};
if (aq.resolved) return null;
const opts = Array.isArray(aq.options) ? aq.options : [];
const chatBox = document.getElementById('chat-history');
const renderOptions = options || {};
const chatBox = renderOptions.root || document.getElementById('chat-history');
const onSubmit = typeof renderOptions.onSubmit === 'function'
? renderOptions.onSubmit
: null;
if (!chatBox || !aq.question || opts.length < 2) return null;
const renderOptions = options || {};
removeAskUserCards(chatBox);
const card = document.createElement('div');
@@ -2342,6 +2388,8 @@ export function renderAskUserCard(payload, options) {
card.setAttribute('role', 'group');
card.tabIndex = -1;
const multi = !!aq.multi;
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
const head = document.createElement('div');
@@ -2350,7 +2398,6 @@ export function renderAskUserCard(payload, options) {
closeBtn.type = 'button';
closeBtn.className = 'modal-close ask-user-close';
closeBtn.setAttribute('aria-label', 'Dismiss question');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => {
card.remove();
const input = uiModule.el('message');
@@ -2366,12 +2413,44 @@ export function renderAskUserCard(payload, options) {
card.appendChild(question);
card.setAttribute('aria-labelledby', question.id);
if (isToolApproval && aq.action) {
const action = document.createElement('div');
action.className = 'ask-user-option-desc';
const effects = Array.isArray(aq.action.effects)
? aq.action.effects.join(', ')
: '';
action.textContent = [
aq.action.tool || 'tool',
aq.action.content || '',
effects ? `Effects: ${effects}` : '',
aq.action.workspace ? `Workspace: ${aq.action.workspace}` : '',
aq.action.document_id ? `Document: ${aq.action.document_id}` : '',
aq.action.document_version != null
? `Document version: ${aq.action.document_version}`
: '',
aq.action.digest ? `Approval fingerprint: ${aq.action.digest}` : '',
].filter(Boolean).join('\n');
action.style.whiteSpace = 'pre-wrap';
card.appendChild(action);
}
const list = document.createElement('div');
list.className = 'ask-user-options';
card.appendChild(list);
const send = (text) => {
if (!text) return;
if (onSubmit) {
const accepted = onSubmit({
kind: 'answer',
text,
label: text,
payload: aq,
card,
});
if (accepted !== false) card.remove();
return;
}
card.remove();
const input = uiModule.el('message');
if (input) input.value = text;
@@ -2403,7 +2482,32 @@ export function renderAskUserCard(payload, options) {
}
if (!multi) {
row.type = 'button';
row.addEventListener('click', () => send(label));
row.addEventListener('click', () => {
if (isToolApproval) {
const detail = {
approval_id: aq.approval_id,
decision: String((opt && opt.value) || '').toLowerCase(),
label,
document_id: aq.action && aq.action.document_id
? String(aq.action.document_id)
: '',
};
if (onSubmit) {
const accepted = onSubmit({
kind: 'tool_approval',
...detail,
payload: aq,
card,
});
if (accepted !== false) card.remove();
} else {
card.remove();
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }));
}
} else {
send(label);
}
});
}
list.appendChild(row);
});
@@ -2439,7 +2543,7 @@ export function renderAskUserCard(payload, options) {
});
other.appendChild(otherInput);
other.appendChild(otherSend);
card.appendChild(other);
if (!isToolApproval) card.appendChild(other);
chatBox.appendChild(card);
if (renderOptions.scroll !== false) {
@@ -2489,7 +2593,7 @@ export function addMessage(role, content, modelName, metadata) {
const toolsByRound = {};
for (const ev of toolEvents) {
const r = ev.round || 1;
const r = ev.round ?? 1;
if (!toolsByRound[r]) toolsByRound[r] = [];
toolsByRound[r].push(ev);
}
@@ -2497,9 +2601,12 @@ export function addMessage(role, content, modelName, metadata) {
const toolRounds = Object.keys(toolsByRound).map(Number);
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
for (let r = 0; r < maxRound; r++) {
const roundNum = r + 1;
const txt = resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata);
const firstRound = (toolsByRound[0] || []).length ? 0 : 1;
for (let roundNum = firstRound; roundNum <= maxRound; roundNum++) {
const r = roundNum - 1;
const txt = r >= 0
? resolveDocumentPlaceholderLinks((roundTexts[r] || '').trim(), metadata)
: '';
if (txt) {
const wrap = document.createElement('div');
@@ -2581,7 +2688,7 @@ export function addMessage(role, content, modelName, metadata) {
box.appendChild(threadWrap);
}
for (const ev of roundTools) {
if (ev.ask_user) pendingAskUser = ev.ask_user;
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
const ok = (ev.exit_code === 0 || ev.exit_code == null);
let outHtml = '';
if (ev.output && ev.output.trim()) {
+32 -3
View File
@@ -7,7 +7,36 @@ import Storage from './storage.js';
import themeModule from './theme.js';
import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
import documentModule from './document.js?v=20260722emailfastindex1';
import documentModule from './document.js?v=20260815approvalsave1';
// Tool approvals are control-plane submits for the current chat. chat.js
// deliberately leaves the composer untouched, then programmatically clicks the
// shared send button after it records the sealed approval id/decision. That
// button is polymorphic: with an empty composer it can mean New chat or Record
// voice instead of Send. Intercept only the programmatic approval click and
// route it through the form submit path, which already reaches chat.js directly.
document.addEventListener('odysseus:tool-approval', () => {
const sendButton = document.querySelector('.send-btn');
const chatForm = document.getElementById('chat-form');
if (!sendButton || !chatForm) return;
const interceptApprovalClick = (event) => {
// A real user click must retain the normal send/new-chat/STT behavior.
if (event.isTrusted) return;
sendButton.removeEventListener('click', interceptApprovalClick, true);
event.preventDefault();
event.stopImmediatePropagation();
if (chatForm.requestSubmit) chatForm.requestSubmit();
else chatForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
};
sendButton.addEventListener('click', interceptApprovalClick, true);
// Fail-safe cleanup if the approval continuation never reaches its deferred
// synthetic click (for example because the surrounding view is torn down).
setTimeout(() => {
sendButton.removeEventListener('click', interceptApprovalClick, true);
}, 60000);
}, true);
/**
* Handle a ui_control SSE event AI-driven UI manipulation.
@@ -156,7 +185,7 @@ export function handleUIControl(uiData) {
if (fn) fn();
}).catch(function(){});
} 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);
if (fn) fn();
}).catch(function(){});
@@ -205,7 +234,7 @@ export function handleUIControl(uiData) {
} catch (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);
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
}).catch(function(e) {
+12 -7
View File
@@ -21,7 +21,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
import {
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
@@ -1006,11 +1006,16 @@ async function _executeCompare(message) {
console.error('Compare error:', err);
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
} finally {
state._streaming = false;
_setSendBtn('send');
// Re-enable header buttons
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
// A pane may have started its own ask_user/approval continuation while the
// original all-pane Promise was settling. Keep Compare busy until every
// pane-owned controller is gone instead of exposing a second broadcast send.
const compareStillStreaming = state._abortControllers.some(Boolean);
state._streaming = compareStillStreaming;
_setSendBtn(compareStillStreaming ? 'stop' : 'send');
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
button.disabled = compareStillStreaming;
button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
button.style.pointerEvents = compareStillStreaming ? 'none' : '';
});
}
}
@@ -1514,7 +1519,7 @@ async function showShufflePoolEditor() {
// ────────────────────────────────────────────────────────────────────────────
registerCompareActions({ stopAll, resetCompare });
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
// ────────────────────────────────────────────────────────────────────────────
+189 -6
View File
@@ -1,7 +1,7 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { addFinishBadge } from './vote.js';
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260819approvalcontrol1';
import markdownModule from '../markdown.js';
import spinnerModule from '../spinner.js';
import uiModule from '../ui.js';
@@ -24,11 +24,157 @@ function _safeHttpHref(raw) {
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
let _rerollPane = null;
let _autoPreviewHtml = null;
let _setSendBtn = null;
/** Register external functions that live in compare.js. */
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
_rerollPane = rerollPane;
_autoPreviewHtml = autoPreviewHtml;
_setSendBtn = setSendBtn;
}
function _paneSessionIsCurrent(paneIdx, sessionId) {
return Boolean(
state.isActive
&& state._paneSessionIds[paneIdx] === sessionId
&& document.getElementById('cmp-history-' + paneIdx)
);
}
function _setCompareBusy(active) {
state._streaming = Boolean(active);
if (_setSendBtn) _setSendBtn(active ? 'stop' : 'send');
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
button.disabled = Boolean(active);
button.style.opacity = active ? '0.25' : '0.7';
button.style.pointerEvents = active ? 'none' : '';
});
}
function _syncCompareBusyFromPanes() {
_setCompareBusy((state._abortControllers || []).some(Boolean));
}
function _appendPaneMessage(hist, role, text) {
const message = document.createElement('div');
message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
const roleEl = document.createElement('div');
roleEl.className = 'role';
roleEl.textContent = role === 'user' ? 'You' : 'AI';
const body = document.createElement('div');
body.className = 'body';
body.textContent = text || '';
message.appendChild(roleEl);
message.appendChild(body);
hist.appendChild(message);
return message;
}
function _createPaneContinuationMessage(hist) {
const message = _appendPaneMessage(hist, 'assistant', '');
const body = message.querySelector('.body');
if (spinnerModule) {
const spinner = spinnerModule.create('Continuing...', 'right');
body.appendChild(spinner.createElement());
spinner.start();
message._spinner = spinner;
}
return message;
}
function _restorePaneAskUserCard(paneIdx, sessionId, submission, originController) {
const hist = document.getElementById('cmp-history-' + paneIdx);
const restored = _renderPaneAskUserCard(
paneIdx,
sessionId,
submission.payload || {},
hist,
null,
originController,
);
if (uiModule) {
uiModule.showError(
restored
? 'This pane is still streaming — choose again once it settles.'
: 'Compare pane is still streaming; the choice was not sent.',
);
}
return restored;
}
function _resumePaneChoiceWhenIdle(paneIdx, sessionId, originController, submission) {
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return false;
const startedAt = Date.now();
const resume = () => {
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return;
const activeController = state._abortControllers[paneIdx];
if (activeController === originController) {
if (Date.now() - startedAt < 10000) {
setTimeout(resume, 25);
return;
}
// The originating stream never released the pane. The card was already
// removed when the choice was accepted, so put it back rather than
// swallowing a decision the user made.
_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);
return;
}
// A reroll/model replacement already owns this pane. Never send the stale
// choice into that replacement stream or session UI.
if (activeController) return;
const hist = document.getElementById('cmp-history-' + paneIdx);
if (!hist) return;
hist.querySelectorAll('.ask-user-card').forEach((card) => card.remove());
const isApproval = submission.kind === 'tool_approval';
const message = isApproval ? '' : String(submission.text || submission.label || '');
if (!isApproval) _appendPaneMessage(hist, 'user', message);
const aiMessage = _createPaneContinuationMessage(hist);
hist.scrollTop = hist.scrollHeight;
const resumeOptions = { skipBadge: true };
if (isApproval) {
resumeOptions.toolApproval = {
approval_id: String(submission.approval_id || ''),
decision: String(submission.decision || '').toLowerCase(),
};
}
_setCompareBusy(true);
streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)
.catch((error) => {
console.error('Compare pane continuation failed:', error);
if (uiModule) uiModule.showError('Compare continuation failed: ' + error.message);
})
.finally(_syncCompareBusyFromPanes);
};
setTimeout(resume, 0);
return true;
}
function _renderPaneAskUserCard(paneIdx, sessionId, payload, hist, aiMsgEl, originController) {
if (!hist || !hist.isConnected || !_paneSessionIsCurrent(paneIdx, sessionId)) return null;
if (aiMsgEl && aiMsgEl._spinner) {
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
const card = renderAskUserCard(payload, {
root: hist,
onSubmit: (submission) => _resumePaneChoiceWhenIdle(
paneIdx,
sessionId,
originController,
submission,
),
});
if (card) {
card.dataset.comparePane = String(paneIdx);
card.dataset.compareSession = String(sessionId);
}
return card;
}
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
@@ -164,6 +310,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
let metrics = null;
let timedOut = false;
let streamOk = false;
let awaitingChoice = false;
let currentToolBlock = null; // track active agent tool block
// Idle timeout — abort only if no data is received for this many seconds.
// Long generations (SVG, big code) are fine as long as the stream stays
@@ -219,6 +366,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
const fd = new FormData();
fd.append('message', message);
fd.append('session', sessionId);
if (opts.toolApproval) {
fd.append('tool_approval_id', opts.toolApproval.approval_id || '');
fd.append('tool_approval_decision', opts.toolApproval.decision || '');
}
// Compare mode determines what tools/features are enabled
const isAgent = state._compareMode === 'agent';
@@ -322,6 +473,36 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
}
// ── Pane-local question / approval selector ──
} else if (json.type === 'ask_user') {
awaitingChoice = true;
_renderPaneAskUserCard(
paneIdx,
sessionId,
json.data || {},
hist,
aiMsgEl,
ac,
);
if (hist) hist.scrollTop = hist.scrollHeight;
// Deny ends as a tiny resolution-only stream, so replace the
// continuation spinner with an explicit pane-local result.
} else if (json.type === 'tool_approval_resolved') {
if (aiMsgEl._spinner) {
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
accumulated = json.decision === 'deny' ? 'Denied.' : 'Approval recorded.';
let target = aiMsgEl._textEl;
if (!target) {
target = document.createElement('div');
target.className = 'compare-text-content';
aiBody.appendChild(target);
aiMsgEl._textEl = target;
}
target.textContent = accumulated;
// ── Tool start (bash, web search agent tool) ──
} else if (json.type === 'tool_start') {
// Finalize any accumulated text before the tool block
@@ -640,19 +821,21 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
// TTFT removed from the header per user request — just show total time.
_timerEl.textContent = _formatMs(_totalMs);
}
state._abortControllers[paneIdx] = null;
if (state._abortControllers[paneIdx] === ac) {
state._abortControllers[paneIdx] = null;
}
// Hide stop button, show response action buttons
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (_paneElFinal) {
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
if (accumulated.trim()) {
if (!awaitingChoice && accumulated.trim()) {
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
}
state._paneMetrics[paneIdx] = metrics;
state._paneElapsed[paneIdx] = _totalMs;
if (!opts.skipBadge) {
if (!opts.skipBadge && !awaitingChoice) {
if (streamOk) {
state._finishOrder++;
if (state._parallel) {
@@ -682,7 +865,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
}
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
if (streamOk && state._expectedAnswer) {
if (streamOk && !awaitingChoice && state._expectedAnswer) {
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
}
// Show copy/reroll buttons now that response exists
+3 -1
View File
@@ -15,6 +15,7 @@ let _getPlatform;
let _serverByVal;
let _isWindows;
let _buildEnvPrefix;
let _psQuote;
let _buildServeCmd;
let _detectBackend;
let _detectToolParser;
@@ -538,7 +539,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
if (srv.downloadDir) payload.local_dir = srv.downloadDir;
if (isWin) {
if (env === 'venv' && envPath) {
payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'conda activate ' + envPath;
}
@@ -652,6 +653,7 @@ export function initDownload(shared) {
_serverByVal = shared._serverByVal;
_isWindows = shared._isWindows;
_buildEnvPrefix = shared._buildEnvPrefix;
_psQuote = shared._psQuote;
_buildServeCmd = shared._buildServeCmd;
_detectBackend = shared._detectBackend;
_detectToolParser = shared._detectToolParser;
+3 -1
View File
@@ -338,6 +338,7 @@ let _sshPrefix;
let _getPlatform;
let _isWindows;
let _buildEnvPrefix;
let _psQuote;
let _loadPresets;
let _savePresets;
let _copyText;
@@ -1971,7 +1972,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
let envPrefix = '';
if (_isWindows()) {
if (_envState.env === 'venv' && _envState.envPath) {
envPrefix = '& ' + (_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'conda activate ' + _envState.envPath;
}
@@ -4402,6 +4403,7 @@ export function initRunning(shared) {
_getPlatform = shared._getPlatform;
_isWindows = shared._isWindows;
_buildEnvPrefix = shared._buildEnvPrefix;
_psQuote = shared._psQuote;
_loadPresets = shared._loadPresets;
_savePresets = shared._savePresets;
_copyText = shared._copyText;
+12 -5
View File
@@ -3934,7 +3934,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
leadingIcon: 'check',
action: 'View Message',
onAction: () => {
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (open) open({
account_id: data.account_id || activeAccountId || null,
@@ -9401,9 +9401,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
/** Save manual edits */
export async function saveDocument({ silent = false, forceVersion = false } = {}) {
if (!activeDocId) return;
if (!activeDocId) return false;
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
if (!textarea) return false;
const savingDocId = activeDocId;
saveCurrentToMap();
const localDoc = docs.get(savingDocId);
@@ -9422,7 +9422,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
});
if (res.status === 404) {
if (silent && localDoc?.language === 'email') {
return;
return false;
}
// Streaming/empty email drafts can leave a local tab pointing at a temp
// or already-deleted document. Do not keep surfacing autosave errors for
@@ -9434,7 +9434,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
_syncDocIndicator();
if (!silent && uiModule) uiModule.showError('Document no longer exists');
return;
return false;
}
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
const doc = await res.json();
@@ -9447,6 +9447,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
_syncDocIndicator();
if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved');
return true;
} catch (e) {
console.error('Failed to save document:', e);
const now = Date.now();
@@ -9454,6 +9455,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document');
_lastAutoSaveErrorAt = now;
}
return false;
}
}
@@ -9736,6 +9738,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const container = document.createElement('div');
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
container.innerHTML = html;
// This container is detached, so the document-scoped flush mdToHtml
// schedules never sees it. Typeset the deferred math before html2pdf
// rasterises, or the PDF gets raw formula source. renderMath() returns
// immediately, without loading KaTeX, when there is nothing pending.
await markdownModule.renderMath(container);
const baseName = _getExportBaseName();
window.html2pdf().set({
margin: 10,
+1 -1
View File
@@ -5,7 +5,7 @@
import spinnerModule from './spinner.js';
import sessionModule from './sessions.js';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260722emailfastindex1';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260815approvalsave1';
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
+4 -4
View File
@@ -5,7 +5,7 @@
import spinnerModule from './spinner.js';
import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1';
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260815approvalsave1';
import settingsModule from './settings.js';
import * as Modals from './modalManager.js';
import { topPortalZ } from './toolWindowZOrder.js';
@@ -23,6 +23,7 @@ import {
_tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON,
} from './emailLibrary/signatureFold.js';
import { state } from './emailLibrary/state.js';
import { getSettings } from './appConfig.js';
import { collapseSidebarToRail } from './modalSnap.js';
import { emailApiUrl } from './emailShared.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -993,8 +994,7 @@ function _syncEmailReminderBellVisibility(enabled) {
async function _loadEmailReminderBellVisibility() {
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await res.json();
const settings = await getSettings();
_syncEmailReminderBellVisibility(settings.reminder_channel === 'email');
} catch (_) {
_syncEmailReminderBellVisibility(false);
@@ -6680,7 +6680,7 @@ function _wireAttachmentHandlers(reader, folder) {
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);
if (typeof load === 'function') {
await load(json.doc_id);
+49 -1
View File
@@ -3,7 +3,7 @@
*/
import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1';
import { loadPanel } from './panels.js';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -15,6 +15,54 @@ const API_BASE = window.location.origin;
let _open = false;
let _galleryResizeHandler = null;
// ── Image editor, loaded on first use ──
// galleryEditor.js plus everything under js/editor/ is 54 modules / 576 KB.
// It used to be a static import here, so every page load paid for it even
// though most sessions never touch the Edit tab. The wrappers below keep the
// three call shapes the rest of this file already uses.
//
// closeEditor() and isEditorOpen() stay synchronous on purpose: if the module
// was never loaded there is no edit session to close, and none can be open.
let _editorMod = null;
let _editorLoading = false;
async function _loadEditor() {
_editorLoading = true;
try {
_editorMod = await loadPanel('editor');
return _editorMod;
} finally {
_editorLoading = false;
}
}
async function openEditor(...args) {
let mod = _editorMod;
if (!mod) {
try {
mod = await _loadEditor();
} catch (e) {
// Previously unreachable — a static import either loaded or the whole
// page failed. Now it can fail on its own (offline before the panel was
// ever cached), so say so instead of doing nothing.
console.error('[gallery] image editor failed to load', e);
uiModule?.showError?.('Failed to load the image editor');
return;
}
}
return mod.openEditor(...args);
}
function closeEditor(...args) {
return _editorMod ? _editorMod.closeEditor(...args) : undefined;
}
// True while the module is still in flight as well — the gallery-close paths
// use this to refuse to tear the container down under an edit that is opening.
function isEditorOpen() {
return _editorLoading || (_editorMod ? _editorMod.isEditorOpen() : false);
}
// Auto-refresh gallery when new image is generated
window.addEventListener('gallery-refresh', (e) => {
if (e?.detail?.source === 'chat-upload' && _sort !== 'recent') {
+2 -2
View File
@@ -3,6 +3,7 @@
// ============================================
import { IS_MAC, isAltGrEvent } from './platform.js';
import { getSettings } from './appConfig.js';
const _defaultKeybinds = {
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
@@ -56,8 +57,7 @@ export function initKeyboardShortcuts(modules) {
window._odysseusKeybinds = { ..._defaultKeybinds };
// Load saved keybinds
fetch('/api/auth/settings', { credentials: 'same-origin' })
.then(r => r.json())
getSettings()
.then(s => { if (s.keybinds) window._odysseusKeybinds = { ..._defaultKeybinds, ...s.keybinds }; })
.catch(() => {});
+202 -63
View File
@@ -10,6 +10,127 @@ import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js'
var escapeHtml = uiModule.esc;
// Mermaid and KaTeX are vendored under /static/lib and fetched on first use.
// Loading them from <head> cost every session ~985 KB on the wire even though
// most chats never contain a diagram or a formula. Both loaders memoise the
// *promise* rather than the resolved library, so concurrent callers share one
// fetch and a double trigger cannot start two loads. A failed load clears the
// memo so the next diagram/formula retries instead of being poisoned forever.
const MERMAID_SRC = '/static/lib/mermaid.min.js';
const KATEX_SRC = '/static/lib/katex/katex.min.js';
const KATEX_CSS = '/static/lib/katex/katex.min.css';
// Marks math emitted before KaTeX finished loading; renderMath() swaps these
// for typeset output. The source stays as readable text inside the span, so a
// load that never completes degrades to plain text rather than to nothing.
const MATH_PENDING_CLASS = 'ody-math-pending';
// KaTeX has no entity syntax: it reads a bare "&" as an alignment marker and
// errors out on anything that is not a valid column break, so "a &lt; b" comes
// back as a red .katex-error instead of a formula. mdToHtml escapes the whole
// string before the math pass, which leaves two spellings of the same
// character at the delimiters — a typed "<" arrives as "&lt;", while a typed
// "&lt;" arrives as "&amp;lt;" — and both have to reach KaTeX as "<".
//
// One alternation, longest form first, so nothing this writes is scanned
// again. Chained .replace() calls cannot do it: unescaping "&amp;" first lets
// the next pass eat the "&lt;" it just produced (the double-unescape CodeQL
// flags), and unescaping it last leaves the entity spelling intact and breaks
// the render. The code-block pass upstream keeps its chained order on purpose
// — Markdown does not decode entities inside code, so "&lt;" there is meant to
// stay visible.
const MATH_SOURCE_ENTITY_RE = /&amp;(?:lt|gt|amp|quot|#39);|&lt;|&gt;|&amp;/g;
const MATH_SOURCE_ENTITIES = {
'&amp;lt;': '<',
'&amp;gt;': '>',
'&amp;amp;': '&',
'&amp;quot;': '"',
'&amp;#39;': "'",
'&lt;': '<',
'&gt;': '>',
'&amp;': '&',
};
function decodeMathSource(text) {
return String(text).replace(MATH_SOURCE_ENTITY_RE, (entity) => MATH_SOURCE_ENTITIES[entity]);
}
let _mermaidPromise = null;
let _katexPromise = null;
let _mathFlushScheduled = false;
function _loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.addEventListener('load', () => resolve(), { once: true });
script.addEventListener('error', () => reject(new Error('Failed to load ' + src)), { once: true });
document.head.appendChild(script);
});
}
function _loadStylesheet(href) {
// Resolves either way: without the stylesheet KaTeX still produces correct
// markup, just unstyled, which beats failing the whole math render.
return new Promise((resolve) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.addEventListener('load', () => resolve(), { once: true });
link.addEventListener('error', () => resolve(), { once: true });
document.head.appendChild(link);
});
}
/**
* Load Mermaid on first use and initialize it once.
*/
export function ensureMermaid() {
return (_mermaidPromise ??= _loadScript(MERMAID_SRC)
.then(() => {
if (!window.mermaid) throw new Error('mermaid global missing after load');
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
return window.mermaid;
})
.catch((err) => {
_mermaidPromise = null;
throw err;
}));
}
/**
* Load KaTeX (script + stylesheet) on first use.
*/
export function ensureKatex() {
return (_katexPromise ??= Promise.all([_loadScript(KATEX_SRC), _loadStylesheet(KATEX_CSS)])
.then(() => {
if (!window.katex) throw new Error('katex global missing after load');
return window.katex;
})
.catch((err) => {
_katexPromise = null;
throw err;
}));
}
// mdToHtml() is synchronous and its callers insert the returned string into the
// DOM themselves, so the placeholders are usually not attached yet when this
// fires. Loading first and scanning afterwards covers that gap: by the time
// KaTeX is in, the caller's innerHTML assignment has long since happened.
//
// setTimeout, not requestAnimationFrame: this has nothing to do with paint, and
// rAF is throttled to a stop in a background tab (and never fires at all in a
// headless browser), which would leave math untypeset until the tab is focused.
function _scheduleMathFlush() {
if (_mathFlushScheduled) return;
_mathFlushScheduled = true;
setTimeout(() => {
_mathFlushScheduled = false;
ensureKatex()
.then(() => renderMath(document))
.catch((e) => console.warn('KaTeX load error:', e));
}, 0);
}
function safeLinkUrl(rawUrl) {
const url = String(rawUrl || '').trim();
if (url.startsWith('#')) {
@@ -631,49 +752,45 @@ export function mdToHtml(src, opts) {
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
const mathBlocks = [];
if (window.katex) {
// Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.).
// Handle before $$/$ so all common delimiters render.
s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
try {
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false }));
return placeholder;
} catch (e) { return match; }
});
// Inline math: \( ... \) — GPT-style inline delimiter. Single-line only
// ([^\n]) so a stray escaped paren in prose can't swallow across lines.
s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => {
try {
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false }));
return placeholder;
} catch (e) { return match; }
});
// Display math: $$...$$
s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => {
try {
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false }));
return placeholder;
} catch (e) { return match; }
});
// Inline math: $...$ — single line only, and Pandoc-style delimiter rules so
// currency doesn't render as math ("$5 to $10"): the opening $ must be
// immediately followed by a non-space, the closing $ must be immediately
// preceded by a non-space and not followed by a digit.
s = s.replace(/(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)/g, (match, math) => {
try {
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false }));
return placeholder;
} catch (e) { return match; }
});
}
let sawPendingMath = false;
// Typeset straight away when KaTeX is already in, otherwise bank the source in
// an inert placeholder for renderMath() to swap once the library lands.
const pushMath = (math, displayMode) => {
const raw = decodeMathSource(math).trim();
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
if (window.katex) {
mathBlocks.push(katex.renderToString(raw, { displayMode, throwOnError: false }));
} else {
sawPendingMath = true;
mathBlocks.push(`<span class="${MATH_PENDING_CLASS}" data-display="${displayMode}">${escapeHtml(raw)}</span>`);
}
return placeholder;
};
// Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.).
// Handle before $$/$ so all common delimiters render.
s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
try { return pushMath(math, true); } catch (e) { return match; }
});
// Inline math: \( ... \) — GPT-style inline delimiter. Single-line only
// ([^\n]) so a stray escaped paren in prose can't swallow across lines.
s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => {
try { return pushMath(math, false); } catch (e) { return match; }
});
// Display math: $$...$$
s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => {
try { return pushMath(math, true); } catch (e) { return match; }
});
// Inline math: $...$ — single line only, and Pandoc-style delimiter rules so
// currency doesn't render as math ("$5 to $10"): the opening $ must be
// immediately followed by a non-space, the closing $ must be immediately
// preceded by a non-space and not followed by a digit.
s = s.replace(/(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)/g, (match, math) => {
try { return pushMath(math, false); } catch (e) { return match; }
});
if (sawPendingMath) _scheduleMathFlush();
// Handle pipe tables
s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => {
@@ -826,19 +943,47 @@ export function renderContent(content) {
}
/**
* Initialize any unprocessed Mermaid diagrams in a container (or whole document)
* Initialize any unprocessed Mermaid diagrams in a container (or whole document).
* Returns a promise so callers can await the (lazy) library load if they need to.
*/
export function renderMermaid(container) {
if (!window.mermaid) return;
initMermaid();
const target = container || document;
const pending = target.querySelectorAll('pre.mermaid:not([data-processed])');
if (pending.length === 0) return;
try {
window.mermaid.run({ nodes: pending });
} catch (e) {
console.warn('Mermaid render error:', e);
}
if (!target || typeof target.querySelectorAll !== 'function') return Promise.resolve();
// Cheap pre-check: no fence on the page means Mermaid is never fetched.
if (target.querySelectorAll('pre.mermaid:not([data-processed])').length === 0) return Promise.resolve();
return ensureMermaid()
.then((mermaid) => {
// Re-query after the load: during streaming the renderer replaces the
// message body repeatedly, so the nodes seen before the fetch are stale.
const nodes = [...target.querySelectorAll('pre.mermaid:not([data-processed])')]
.filter((node) => node.isConnected);
if (nodes.length === 0) return;
return mermaid.run({ nodes });
})
.catch((e) => { console.warn('Mermaid render error:', e); });
}
/**
* Typeset any math that mdToHtml() had to defer because KaTeX was not loaded
* yet. Once KaTeX is in, mdToHtml() renders inline and this finds nothing.
*/
export function renderMath(container) {
const target = container || document;
if (!target || typeof target.querySelectorAll !== 'function') return Promise.resolve();
if (target.querySelectorAll('.' + MATH_PENDING_CLASS).length === 0) return Promise.resolve();
return ensureKatex()
.then((katex) => {
target.querySelectorAll('.' + MATH_PENDING_CLASS).forEach((el) => {
const displayMode = el.getAttribute('data-display') === 'true';
try {
el.outerHTML = katex.renderToString(el.textContent || '', { displayMode, throwOnError: false });
} catch (e) {
// Leave the source visible — readable, just not typeset.
el.classList.remove(MATH_PENDING_CLASS);
}
});
})
.catch((e) => { console.warn('KaTeX render error:', e); });
}
const markdownModule = {
@@ -853,20 +998,14 @@ const markdownModule = {
extractThinkingBlocks,
normalizeThinkingMarkup,
startsWithReasoningPrefix,
renderMermaid
renderMermaid,
renderMath,
ensureMermaid,
ensureKatex
};
export default markdownModule;
// Mermaid is loaded async so it cannot delay the app shell.
function initMermaid() {
if (!window.mermaid || window.__odysseusMermaidReady) return;
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
window.__odysseusMermaidReady = true;
}
window.odysseusInitMermaid = initMermaid;
initMermaid();
// Persist which thinking sections were expanded across page refreshes.
// IDs are render-generated (Date.now-based) so we key by a stable hash of
// the inner text content instead — same content reproduces the same hash on
+53
View File
@@ -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
View File
@@ -4,20 +4,21 @@
* Search settings management reads active provider from admin settings.
*/
let API_BASE = '';
import { getSettings, invalidateSettings } from './appConfig.js';
let _provider = 'searxng';
let _loaded = false;
export function init(apiBase) {
API_BASE = apiBase;
// No API base parameter any more: the settings request lives in appConfig.js and
// resolves against the document origin, which is exactly what API_BASE held.
export function init() {
// Fetch provider on init so it's ready when chat needs it
_fetchProvider();
}
async function _fetchProvider() {
try {
const res = await fetch((API_BASE || '') + '/api/auth/settings', { credentials: 'same-origin' });
const s = await res.json();
const s = await getSettings();
_provider = s.search_provider || 'searxng';
_loaded = true;
} catch (e) { /* keep default */ }
@@ -39,6 +40,9 @@ export function getProviderLabel() {
/** Re-fetch after admin saves new settings */
export function refresh() {
// Drop the shared snapshot first: the point of this call is to observe the
// settings that were just written, so it must not be served from cache.
invalidateSettings();
_fetchProvider();
}
+1 -1
View File
@@ -3,7 +3,7 @@
import Storage from './storage.js';
import uiModule, { autoResize, styledPrompt } from './ui.js';
import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
import { providerLogo } from './providers.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
import themeModule from './theme.js';
+130 -217
View File
@@ -3,144 +3,83 @@
import uiModule from './ui.js';
import searchModule from './search.js';
import { makeWindowDraggable } from './windowDrag.js';
import { clearDockSide } from './modalSnap.js';
import { byId } from './settings/dom.js';
import {
getSettingsRegistryIssues,
isAdminManagedSettingsTab,
} from './settings/registry.js';
import { bindSettingsSearch } from './settings/search.js';
import { bindSettingsSidebar } from './settings/sidebar.js';
import {
activateSettingsPanel,
getActiveSettingsTab,
bindSettingsNavigation,
} from './settings/navigation.js';
import {
bindSettingsDrag,
bindSettingsClose,
bindOpenPromptModalLink,
showSettingsModal,
hideSettingsModal,
} from './settings/lifecycle.js';
import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
import { invalidateSettings } from './appConfig.js';
let initialized = false;
let modalEl = null;
let _authPolicy = { password_min_length: 8 };
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 safeRasterDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
/* ── Tab switching ── */
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
/* ── Settings shell coordination ── */
function onSettingsPanelActivated(tab) {
// Appearance keeps its existing transparent preview behavior.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
function initTabs() {
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.settingsTab;
// Lazy-init admin when first clicking an admin tab
if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return;
}
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
// Mark when the Appearance tab is open so the modal can go
// semi-transparent — lets the user see the rest of the UI react as
// they flip toggles instead of having to close + reopen the modal.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
if (tab === 'ai') refreshAiModelEndpoints();
});
});
// AI endpoints are intentionally refreshed only when entering the AI panel.
if (tab === 'ai') refreshAiModelEndpoints();
}
/* ── Dragging ── */
function initDrag() {
const header = modalEl.querySelector('.modal-header');
const content = modalEl.querySelector('.settings-modal-content');
if (!header || !content) return;
// Skip interactive controls in the header (e.g. the opacity slider) so
// grabbing them doesn't start a window-drag.
makeWindowDraggable(modalEl, {
content,
header,
skipSelector: 'button, input, select, .theme-opacity-wrap',
enableDock: true,
});
}
function resetWindowPlacement() {
const content = modalEl && modalEl.querySelector('.settings-modal-content');
if (!content) return;
const hadLeft = modalEl.classList.contains('modal-left-docked');
const hadRight = modalEl.classList.contains('modal-right-docked');
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
if (hadLeft) clearDockSide('left', modalEl);
if (hadRight) clearDockSide('right', modalEl);
if (content._leftDockNavObs) {
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
function openAdminSettingsTab(tab) {
if (window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return true;
}
delete content._preDockSnapshot;
delete content._dockSide;
delete content._dockSuspended;
delete content.dataset._tilePreSnap;
delete content.dataset._tileZone;
[
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
].forEach(prop => content.style.removeProperty(prop));
}
/* ── Delegated link: close Settings + open the Prompt (characters) modal ── */
function initOpenPromptModalLink() {
document.addEventListener('click', async (e) => {
const link = e.target.closest('[data-open-prompt-modal]');
if (!link) return;
e.preventDefault();
// Close settings first so the prompt modal isn't stacked on top.
if (modalEl && !modalEl.classList.contains('hidden')) close();
try {
const m = await import('./presets.js');
const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal);
if (typeof fn === 'function') fn();
} catch (_) {
const modal = document.getElementById('custom-preset-modal');
if (modal) modal.classList.remove('hidden');
}
// Force the Persona tab (data-chartab="character") since the link's
// whole purpose is editing personas — not landing on Inject by default.
const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]');
if (personaTab) personaTab.click();
});
}
/* ── Close on backdrop / X ── */
function initClose() {
modalEl.querySelector('.close-btn').addEventListener('click', close);
modalEl.addEventListener('mousedown', e => {
if (uiModule.isTouchInsideModal()) return;
if (e.target === modalEl) close();
});
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return;
// Bail when a transient popover inside the modal is open — Esc should
// dismiss just that, not the whole modal. Same-document listeners fire
// in registration order regardless of capture/bubble, so the popover's
// own handler can't pre-empt ours; we have to opt out here.
const popoverOpen = modalEl.querySelector(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
);
if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) {
return;
}
// If an integration edit/add form is open inside the modal, close
// just that — don't dismiss the whole settings modal. (Pressing
// ESC mid-edit and losing the modal was a fast-typing footgun.)
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
e.preventDefault();
e.stopPropagation();
innerForm.style.display = 'none';
innerForm.innerHTML = '';
return;
}
e.preventDefault();
e.stopPropagation();
close();
});
return false;
}
/* Appearance-tab opacity slider
@@ -363,10 +302,7 @@ function _bindFallbackWidget(opts) {
var body = {};
body[settingKey] = clean;
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
await _postSettings(body);
} catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
}
@@ -476,12 +412,9 @@ async function initDefaultChat() {
async function saveDefault() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
default_endpoint_id: epSel.value,
default_model: modelSel.value
})
await _postSettings({
default_endpoint_id: epSel.value,
default_model: modelSel.value
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
@@ -536,12 +469,9 @@ async function initUtilityModel() {
// no toggle, "—" means "unset, use chat").
async function saveUtility() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
utility_endpoint_id: epSel.value || '',
utility_model: modelSel.value || ''
})
await _postSettings({
utility_endpoint_id: epSel.value || '',
utility_model: modelSel.value || ''
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 1500);
@@ -634,10 +564,7 @@ async function initTeacherModel() {
spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value;
}
var enabled = enabledToggle ? !!enabledToggle.checked : false;
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teacher_enabled: enabled, teacher_model: spec })
});
await _postSettings({ teacher_enabled: enabled, teacher_model: spec });
msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled';
msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
@@ -712,8 +639,7 @@ async function initImageSettings() {
async function saveSettings() {
try {
const res = await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }) });
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -787,8 +713,7 @@ async function initVisionSettings() {
async function saveSettings() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value }) });
await _postSettings({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value });
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
@@ -869,8 +794,7 @@ async function initTtsSettings() {
async function saveTTS() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' }) });
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
@@ -1031,9 +955,7 @@ async function initSttSettings() {
async function saveSTT() {
try {
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() }) });
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
// Notify voiceRecorder of effective provider and update send button icon
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
@@ -1189,10 +1111,7 @@ async function initSearchSettings() {
payload[kf] = keyInput.value.trim();
_settings[kf] = keyInput.value.trim();
}
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
await _postSettings(payload);
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(refreshStatus, 2000);
if (searchModule && searchModule.refresh) searchModule.refresh();
@@ -1344,11 +1263,7 @@ async function initSearchSettings() {
async function _saveFallbackChain(chain) {
_settings.search_fallback_chain = chain;
try {
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ search_fallback_chain: chain }),
});
await _postSettings({ search_fallback_chain: chain });
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(refreshStatus, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1512,10 +1427,7 @@ async function initResearchSettings() {
}
}
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
await _postSettings(payload);
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(showStatus, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1579,10 +1491,7 @@ async function initResearchSearchSettings() {
async function saveResearchSearch() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ research_search_provider: searchSel.value })
});
await _postSettings({ research_search_provider: searchSel.value });
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1624,10 +1533,7 @@ async function initAgentSettings() {
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
@@ -2022,11 +1928,7 @@ async function initShortcuts() {
async function saveKeybinds() {
try {
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keybinds }),
});
await _postSettings({ keybinds });
// Update global keybinds so they take effect immediately
window._odysseusKeybinds = keybinds;
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
@@ -2238,10 +2140,39 @@ function initAccount() {
function initAll() {
modalEl = el('settings-modal');
initTabs();
initDrag();
initClose();
initOpenPromptModalLink();
bindSettingsNavigation(modalEl, {
openAdminTab: openAdminSettingsTab,
onPanelActivated: onSettingsPanelActivated,
});
bindSettingsSearch(modalEl, {
isAdmin: () => !!window._isAdmin,
openPanel(tab) {
const button = modalEl.querySelector(`[data-settings-tab="${tab}"]`);
if (button) button.click();
},
});
bindSettingsSidebar(modalEl);
const registryIssues = getSettingsRegistryIssues(modalEl);
if (registryIssues.length) {
console.warn('Settings registry/DOM mismatch:', registryIssues);
}
bindSettingsDrag(modalEl);
bindSettingsClose(modalEl, {
closeSettings: close,
isTouchInsideModal: () => uiModule.isTouchInsideModal(),
});
bindOpenPromptModalLink({
getModal: () => modalEl,
closeSettings: close,
});
initOpacityToggle();
initialized = true;
initDefaultChat();
@@ -2290,11 +2221,7 @@ async function initReminderSettings() {
pubDebounce = setTimeout(async () => {
try {
const val = pubUrlIn.value.trim().replace(/\/+$/, '');
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_public_url: val }),
});
await _postSettings({ app_public_url: val });
if (pubUrlMsg) {
pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)';
pubUrlMsg.style.color = 'var(--green,#50fa7b)';
@@ -2592,12 +2519,7 @@ async function initReminderSettings() {
async function save(patch) {
try {
await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
await _postSettings(patch);
} catch (e) { console.warn('Failed to save reminder settings', e); }
}
@@ -2745,7 +2667,7 @@ async function initEmailAccountsSettings() {
el('set-email-open-library-settings')?.addEventListener('click', async () => {
try {
const mod = await import('./emailLibrary.js?v=20260722emailfastindex1');
const mod = await import('./emailLibrary.js?v=20260815approvalsave1');
if (typeof mod.openEmailLibrarySettings === 'function') {
await mod.openEmailLibrarySettings();
}
@@ -5661,44 +5583,35 @@ function syncAdminVisibility() {
*/
export function open(tab) {
if (!initialized) initAll();
syncAppearanceCheckboxes();
if (modalEl.classList.contains('hidden')) {
resetWindowPlacement();
}
modalEl.classList.remove('hidden');
showSettingsModal(modalEl);
syncAdminVisibility();
const content = modalEl.querySelector('.settings-modal-content');
if (tab) {
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
activateSettingsPanel(modalEl, tab);
}
// Auto-init admin data if showing an admin tab
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
syncAppearanceOpacity(activeTab === 'appearance');
if (activeTab === 'ai') refreshAiModelEndpoints();
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
// Preserve existing panel-specific side effects when Settings is opened
// directly to a tab as well as when the user navigates there.
const activeTab = tab || getActiveSettingsTab(modalEl);
onSettingsPanelActivated(activeTab);
// Auto-init admin data if showing an admin tab.
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
window.adminModule._initData();
}
}
export function close() {
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');
syncAppearanceOpacity(false); // clear any opacity-slider fade
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
} else {
modalEl.classList.add('hidden');
}
syncAppearanceOpacity(false);
hideSettingsModal(modalEl);
}
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
+7
View File
@@ -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);
}
+176
View File
@@ -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');
}
+57
View File
@@ -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);
}
});
});
}
+237
View File
@@ -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;
}

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