Commit graph

1037 commits

Author SHA1 Message Date
holden093
8375322d70 fix(oidc): serialize auth across workers, guard UserInfo demotion, atomic key creation
- Add fcntl.flock inter-process file lock shared by setup() and
  create_user_oidc() so multi-worker first-admin bootstrap is
  serialised across processes, not just threads within one worker.
  Both methods reload auth.json inside the lock so the loser sees
  the winner's write.

- _fetch_userinfo() now returns None (not {}) when discovery has
  no userinfo_endpoint, and exchange_code() only sets
  _userinfo_available=True when a live endpoint was reached.
  Prevents the callback from treating 'no endpoint' as
  authoritative group non-membership evidence.

- Rewrite _load_or_create_key() to write the Fernet key to a temp
  file, fsync, then atomically os.link() into place.  No reader
  ever sees the final path before the complete key bytes are
  available — a racing worker either sees no file or a complete
  one, never an empty/partial file.

105 tests pass (97 existing + 8 new regressions covering the
three fixes).

Co-Authored-By: Kevin <holden093@users.noreply.github.com>
2026-07-25 18:32:56 +02:00
holden093
739eb0faf1 fix(oidc): serialize admin bootstrap, atomic key creation, guard against silent demotion
Three fixes from second review pass:

1. (security) Serialize the first-OIDC-user admin bootstrap inside
   _config_lock.  Previously the  check and username
   collision resolution happened outside the lock, so two concurrent
   first-login callbacks could both observe an empty user map and
   both persist as admin.  Now idempotent lookup, bootstrap decision,
   and collision resolution are all inside one critical section.

2. (auth) Make data/.app_key creation atomic via O_EXCL open so two
   racing workers on a fresh deployment cannot generate different
   keys.  The loser reads the winner's key, guaranteeing every worker
   shares the same Fernet key for OIDC state encryption.

3. (auth) Track whether UserInfo was successfully fetched
   (_userinfo_available flag in claims).  The callback now skips
   admin group sync for existing users when UserInfo is unavailable
   AND the id_token lacks a groups claim — a transient provider
   failure no longer silently demotes existing OIDC admins.  When
   UserInfo succeeds or the id_token carries groups, admin status
   syncs as before.

Regression tests: concurrent admin bootstrap, key-creation race,
UserInfo-unavailable preserves admin, UserInfo-available demotes,
id_token groups authoritative without UserInfo.
2026-07-25 18:32:56 +02:00
holden093
9215195631 fix(oidc): throttle failed JWKS refreshes and persist state key across workers
Two fixes from review feedback:

1. (security/availability) Record _last_jwks_refresh timestamp BEFORE
   calling _refresh_jwks() so a failed JWKS fetch is also throttled
   by the 60-second cooldown.  Previously the timestamp was only set
   after success, so an outage or key rotation would retry the IdP
   on every login attempt.

2. (auth) Use the shared persistent app key from secret_storage
   (_get_fernet) for OIDC state encryption instead of reading
   data/.app_key directly and falling back to a per-process key.
   This guarantees all uvicorn workers share the same Fernet key,
   even on a fresh data directory — worker A's state is always
   decryptable by worker B on the callback.

Add regression tests for both fixes.
2026-07-25 18:32:56 +02:00
holden093
e1863e973b fix(oidc): offload code exchange off the event loop to avoid blocking
Wrap oidc_manager.exchange_code() in asyncio.to_thread() so slow
provider I/O (token exchange, JWKS refresh, UserInfo) doesn't block
the async worker's event loop.  Moves import asyncio to top level.

Add regression test proving a 0.3s blocking exchange completes
quickly without blocking concurrent async work.
2026-07-25 18:32:56 +02:00
holden093
d303739228 fix(oidc): wrap JWKS errors as OidcError, bind token exchange to stored redirect_uri
1. JWKS fetch/parse errors now wrapped as OidcError instead of escaping
   as raw exceptions. Transient network failures, HTTP errors, and bad
   JSON responses from the JWKS endpoint all produce a controlled
   OidcError, which the callback route already redirects to
   /login?error=oidc_failed instead of a 500.

2. exchange_code() now extracts the stored redirect_uri from the
   Fernet-encrypted state token and rejects any callback-derived
   redirect_uri that differs. The token exchange POSTs the stored
   value — the one the IdP saw in the authorization request — removing
   the last callback dependence on request-derived URI behaviour.

Tests: 5 new (3 JWKS error wrapping + 2 redirect_uri binding)
Total: 130 passing (81 OIDC + 49 regression), 0 failures.
2026-07-25 18:32:56 +02:00
holden093
c87bcfb8cb fix(oidc): address remaining review items — issuer fail-closed, multi-audience azp, OIDC 2FA guards
1. Discovery issuer mismatch now raises OidcError instead of logging
   a warning (OIDC Discovery §1.1 requires mismatch abort).

2. Multi-audience ID tokens without azp are now rejected (OIDC Core
   §2 requires azp when aud has multiple values).

3. /change-password, /2fa/setup, /2fa/confirm, and /2fa/disable now
   reject OIDC users with a clear message. The frontend already hides
   these cards, but the backend must also enforce the policy.

113 passing (76 OIDC + 37 regression), 0 failures.
2026-07-25 18:32:56 +02:00
holden093
7c3c4db8db fix(oidc): address RaresKeY review — bootstrap gating and UserInfo sub protection
1. Suppress first-user bootstrap admin when OIDC_ADMIN_GROUPS is
   configured — a non-admin IdP user must not get admin just by being
   the first to log in. Group membership is the only path when groups
   are set.
2. Reject mismatched UserInfo sub: the UserInfo endpoint MUST NOT
   overwrite the verified id_token subject. Also guard all verified
   identity claims (sub, iss, aud, exp, iat, nonce, azp) from being
   overwritten by UserInfo.
2026-07-25 18:32:56 +02:00
holden093
873fd1997d fix(oidc): address second-pass review — CSRF cookie, admin demotion, JWKS cooldown, secure cookies, dead code
1. Fix bootstrap admin demotion: skip set_oidc_user_admin when
   OIDC_ADMIN_GROUPS is unset so bootstrap/manual admin survives
   subsequent logins.
2. Login CSRF: bind state to an HttpOnly cookie set at /login and
   verified with constant-time compare at /callback.
3. JWKS cooldown: throttle refresh to once per 60s to prevent
   attacker-triggered unbounded IdP fetches via random kid values.
4. Secure cookies: derive secure flag from request scheme when
   SECURE_COOKIES is not explicitly set; OIDC session defaults
   to secure on HTTPS connections.
5. Remove dead validation-shaped code: the jwt.decode block with
   None key that swallowed all exceptions and discarded output.
2026-07-25 18:32:56 +02:00
holden093
f75e4bd68d fix(oidc): address review items — aud arrays, JWKS cache, alg pinning, redirect_uri, stateless state, first-user-admin
1. aud validation: handle JSON array audiences per OIDC spec (check
   membership, validate azp for multi-audience tokens).
2. JWKS caching: cache keys after first fetch; refresh only on unknown
   kid — avoids live IdP round-trip on every login.
3. Algorithm pinning: restrict to RS256/ES256/etc from discovery doc,
   never allow HS256 or 'none'.
4. OIDC_REDIRECT_URI: support a fixed redirect URI (proxy-safe,
   doesn't trust Host header).
5. Stateless state: replace in-memory dict with Fernet-encrypted state
   tokens — works across multiple uvicorn workers/processes.
6. OIDC_FIRST_USER_IS_ADMIN: bootstrap first OIDC user as admin when
   no users exist and OIDC_ADMIN_GROUPS is unset — prevents zero-admin
   lockout.
2026-07-25 18:32:56 +02:00
holden093
84cb22039b feat(auth): add generic OpenID Connect (OIDC) single sign-on
Adds OIDC authentication as an alternative to password login, enabling
sign-in via any standard provider (Authentik, Keycloak, Authelia, etc.).

New features:
- Generic OIDC provider support via .well-known discovery (authlib)
- Coexists with existing password auth — users choose at login
- Auto-creates local users on first OIDC login
- Admin group mapping: OIDC_ADMIN_GROUPS grants admin based on IdP groups
- Admin status syncs on every login (follows IdP membership)
- OIDC users cannot use password login or set up 2FA
- UI hides change-password and 2FA cards for OIDC users

New env vars:
- OIDC_ENABLED, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET
- OIDC_SCOPES, OIDC_ADMIN_GROUPS

New files:
- core/oidc.py — OidcManager (discovery, auth URL, code exchange,
  id_token verification with JWT/JWKS)
- routes/oidc_routes.py — /api/auth/oidc/{login,callback,config}

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:32:56 +02:00
pewdiepie-archdaemon
d8a2059df8 Merge verified Odysseus fixes
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
ci / docker publish / build (amd64) (push) Has been cancelled
ci / docker publish / build (arm64) (push) Has been cancelled
ci / docker publish / merge manifest + tag (push) Has been cancelled
2026-07-23 14:49:02 +00:00
Joeseph Grey
4c9a8ca115 fix(rag): skip hidden and junk directories when indexing (#5633)
* fix(rag): skip hidden and junk directories when indexing (#5559)

index_personal_documents walked the whole tree with no pruning, so
pointing RAG at a real-world folder silently swept in .obsidian/ plugin
JS, .git/ internals, node_modules/, and __pycache__/ — multiplying
indexing time and polluting retrieval with junk chunks.

Prune hidden directories and well-known junk directories from the walk,
and skip hidden files. The explicitly passed root is exempt, so a user
who deliberately indexes a hidden directory still gets its contents.

* fix(rag): prune hidden/junk dirs in the keyword index too, via a shared helper

The #5559 fix pruned only VectorRAG.index_personal_documents (the vector index).
The parallel keyword index built by PersonalDocsManager.refresh_index ->
load_personal_index walked the same tree unpruned, so .obsidian/, .git/,
node_modules/ etc. still swept into keyword retrieval and the file listing —
the 'end-to-end' guarantee was only half true.

Single-source the pruning policy in src/index_walk (prune_index_dirs +
is_indexable_file) and use it from both walkers so they cannot drift again.
The junk-dir match is now case-insensitive, so a Node_Modules on a
case-insensitive filesystem is pruned too.

Tests: keyword-path regressions covering hidden/junk dirs, hidden files, junk
at depth (not just top level), case-insensitive junk, and the explicit-hidden-
root exemption. The existing vector tests still pass against the shared helper.
2026-07-23 14:18:08 +02:00
RaresKeY
d49629fa14 fix(reminders): support OAuth SMTP accounts (#5649) 2026-07-22 16:03:35 +02:00
RaresKeY
65987fc772 fix(email): test saved OAuth accounts through shared transports (#5653)
* fix(email): use XOAUTH2 in test-connection for Google OAuth accounts

The test-connection endpoint was password-only and had no awareness of
OAuth accounts. For Google-connected accounts this caused two failures:
- IMAP: "Need IMAP host, username, and password" because imap_pass is
  empty (no password is stored for OAuth accounts)
- SMTP: 535 BadCredentials because smtp.login() was called with an
  empty password instead of an XOAUTH2 token

Fix: include oauth_provider and token fields in saved_body when hydrating
from the DB, then use conn.authenticate("XOAUTH2") / smtp.auth("XOAUTH2")
for Google accounts in both the IMAP and SMTP test paths, mirroring what
_send_smtp_message already does for real sends.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(email): add OAuth2 tests for test-connection endpoint

Covers the XOAUTH2 changes made to routes/email_routes.py:
- Google OAuth accounts must not be rejected with 'Need IMAP host,
  username, and password' (no stored password for OAuth accounts)
- IMAP and SMTP test paths must use conn.authenticate('XOAUTH2')
  for Google accounts
- Password accounts must still use conn.login() / smtp.login()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(email): bind OAuth account tests to Google transport

---------

Co-authored-by: TNTBA <trynottobreakanything@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:54:38 +02:00
RaresKeY
dcc7e52a86 fix(config): forward Google email OAuth settings through Compose (#5650)
* fix(config): forward Google OAuth env vars into Docker container and document setup

GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, and GOOGLE_OAUTH_REDIRECT_URI
were read by the app but never forwarded through docker-compose.yml's explicit
environment allowlist, causing the "not set" error even when the vars existed in .env.

Also adds a documented section to .env.example with step-by-step GCP setup instructions
so users know where to get the credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(config): cover OAuth in standalone compose files

* test(config): parse OAuth compose service env

* test(config): keep checkout skip wording neutral

---------

Co-authored-by: TNTBA <trynottobreakanything@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:59:22 +02:00
RaresKeY
93f97e74cd fix(email): hide OAuth tokens from config response (#5651) 2026-07-22 12:58:16 +02:00
RaresKeY
16b04a9792 fix(email): verify mailbox identity on OAuth reconnect (#5648) 2026-07-22 12:56:17 +02:00
Tal.Yuan
483c42bb12 refactor(routes): move compare domain into routes/compare/ subpackage (#5660)
Slice 2i of the route-domain reorganization (#4082/#4071). Moves
compare_routes.py into routes/compare/, leaving a backward-compat
sys.modules shim at the old path. Pure file reorganization, no behavior
change.

The shim uses sys.modules replacement so the `import ... as cr` +
`monkeypatch.setattr(cr, "SessionLocal", ...)` / `"_owned_endpoint_by_url"`
/ `"_owned_endpoint_by_id"` pattern in test_endpoint_owner_scope_followup.py
reaches the canonical module.

Canonical module imports only from core/, src/, and routes.session_routes
(zero dependency on the legacy shim). One source-introspection test site
repointed: test_endpoint_owner_scope_followup.py (shared with other domains;
only the compare entry repointed here).

Adds tests/test_compare_routes_shim.py to pin the sys.modules shim
contract. Verified: compileall clean; targeted tests pass.
2026-07-21 12:40:09 +02:00
Tal.Yuan
833cdbd140 refactor(routes): move admin_wipe domain into routes/admin_wipe/ subpackage (#5659)
Slice 2h of the route-domain reorganization (#4082/#4071). Moves
admin_wipe_routes.py into routes/admin_wipe/, leaving a backward-compat
sys.modules shim at the old path. Pure file reorganization, no behavior
change.

The shim uses sys.modules replacement so the `import ... as
admin_wipe_routes` + `monkeypatch.setattr(admin_wipe_routes, "SessionLocal",
...)` / `"require_admin"` pattern in test_admin_wipe_gallery.py reaches
the canonical module.

Canonical module imports only from core/, src/, and stdlib (zero internal
routes/ coupling). Zero source-introspection landmines.

Adds tests/test_admin_wipe_routes_shim.py to pin the sys.modules shim
contract. Verified: compileall clean; targeted tests pass.
2026-07-21 12:39:27 +02:00
Tal.Yuan
b7d3f2a28d refactor(routes): move cleanup domain into routes/cleanup/ subpackage (#5658)
Slice 2g of the route-domain reorganization (#4082/#4071). Moves
cleanup_routes.py into routes/cleanup/, leaving a backward-compat
sys.modules shim at the old path. Pure file reorganization, no behavior
change.

The shim uses sys.modules replacement so string-targeted
monkeypatch.setattr("routes.cleanup_routes.*", ...) in
test_cleanup_owner_scope.py reaches the canonical module.

Canonical module imports only from src/ and stdlib (zero internal
routes/ coupling). Zero source-introspection landmines.

Adds tests/test_cleanup_routes_shim.py to pin the sys.modules shim
contract. Verified: compileall clean; targeted tests pass.
2026-07-21 12:38:32 +02:00
RaresKeY
cc4c7f4263 chore: update repository URLs after organization transfer (#5622) 2026-07-20 16:43:47 +02:00
Tal.Yuan
88e0ce3037 refactor(routes): move note domain into routes/note/ subpackage (#5236)
Slice 2f of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves note_routes.py into
routes/note/, leaving a backward-compat sys.modules shim at the old path.
Pure file reorganization, no behavior change.

The shim uses sys.modules replacement (same pattern as the merged gallery
#4903, research #4975, memory #5007, history #5090, and contacts #5227
slices) so that `import routes.note_routes`, `from routes.note_routes import
X`, `importlib.import_module(...)`, and the `import ... as note_routes` +
`monkeypatch.setattr(note_routes, "SessionLocal", ...)` pattern used by
test_note_reminder_fire_scope.py / test_notes_fail_closed_auth.py all
operate on the same module object the application uses.

The canonical module does NOT depend on the shim — routes/note/note_routes.py
imports only from core/, src/, and stdlib. The outbound email cross-domain
imports (routes.email_routes._get_email_config, routes.email_helpers.
_send_smtp_message) are function-local lazy imports that keep resolving
through the email module's own path (email is not yet migrated).

One source-introspection test site repointed to the new canonical path:
- test_model_helper_owner_scope.py (shared with history; history entry
  already repointed in #5090, note entry repointed here)

Adds tests/test_note_routes_shim.py to pin the sys.modules shim contract
(legacy and canonical paths resolve to the same module object; monkeypatch
via legacy alias reaches the canonical module).

Verified: compileall clean; full suite 4487 passed, 3 skipped.
2026-07-20 13:52:30 +02:00
Afonso Coutinho
1aad1db9f6 fix: services research source extraction crashes on a non-dict finding (#1868) 2026-07-20 09:39:16 +02:00
Abhishek Kumbhar
d05900cb90 fix(llm): enhance fallback logic to handle empty completions and impr… (#5491)
* fix(llm): enhance fallback logic to handle empty completions and improve metadata handling

* fix(llm): stream tool call deltas immediately
2026-07-18 22:06:14 +01:00
Joeseph Grey
b3f8b77317 fix(url-safety): reject RFC 6598 shared address space in strict mode (#5474)
* security(url-safety): reject RFC 6598 shared address space in strict mode

Strict mode (block_private=True) is a full SSRF lockdown, but it only
rejected is_private and is_loopback targets. CPython does not classify RFC
6598 shared/CGNAT space (100.64.0.0/10) as is_private (it is "shared", not
"private"), so a public redirect into 100.64.0.1 passed the per-hop guard
and still issued the request to a potentially internal CGNAT service.

not is_global would also exclude it, but only on CPython 3.11.10+/3.12.4+/
3.13+; the CI matrix runs 3.11/3.12, so reject the range explicitly to stay
correct across patch levels and the 3.14 runtime image. Default local-first
mode is unchanged. Adds strict-mode coverage for shared, non-global, and
public targets.

* docs(url-safety): correct CGNAT is_global rationale in strict-mode comment

The prior comment claimed `not is_global` catches 100.64.0.0/10 only on
CPython 3.11.10+/3.12.4+/3.13+. That is inaccurate for CGNAT: is_global
is False for 100.64.0.1 on every supported version (verified 3.10-3.14).
The version-fragility applies to other ranges gh-113171 touched, not CGNAT.
The explicit range reject is still the right choice; restate the reason as
is_private not covering shared space, and not coupling strict mode to
is_global's broader, cross-version definition. No behavior change.
2026-07-18 12:36:27 -06:00
RaresKeY
5f481e7db1 test(cookbook): cover adopt remote host validation (#5225) 2026-07-18 12:07:48 +01:00
RaresKeY
0d47b78f47 test(email): cover agent draft owner isolation (#5226) 2026-07-18 11:30:28 +01:00
RaresKeY
b9cafd67a1 feat(models): define capability schema and readers (#2739)
* feat(models): define capability schema and readers

* fix(models): harden Google catalog probing

Restrict native catalog probing to the Gemini host, keep provider keys out of request URLs, filter non-chat model resources, and preserve the manual refresh default in the built-in Google add flow.
2026-07-18 09:40:58 +01:00
Emir Çoban
45b6771e73 fix(skills): block private SSRF targets and revalidate redirects in importer (#5261)
* Harden skill importer against SSRF: block private targets + revalidate redirects per hop

The skill importer validated only the initial URL with the lenient SSRF guard
(block_private=False) and then fetched with follow_redirects=True, so a 3xx to
an internal/metadata address (169.254.169.254, 127.0.0.1, RFC-1918) was still
connected to — inconsistent with the hardened services/search/content.py
:_get_public_url path.

Add a _get_checked() helper that follows redirects manually and re-runs the
SSRF guard with block_private=True on every hop, and route all three fetch
sites (skills.sh unwrap, _fetch_bytes, _list_github_dir) through it. GitHub's
own redirects and the final-host _assert_github_url checks are preserved.

Adds hermetic regression tests (IP-literal hosts, faked HTTP layer) and updates
the existing mock signature for the new block_private kwarg.

Defense-in-depth: the endpoint is admin-gated (require_admin) and admins are
trusted per THREAT_MODEL.md, so this is not a cross-boundary vulnerability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: enforce follow_redirects=False invariant in mock client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:33:10 -06:00
Boody
0d1d28c7d6 fix(mcp_manager): implement concurrent server connections with timeout handling 2026-07-13 08:56:46 +02:00
Ethan
c7b3b84f24 fix(db): restrict data/app.db to 0600 (#4420)
* fix(db): restrict data/app.db to 0600

app.db holds bearer-token hashes, bcrypt password hashes, and encrypted
provider keys but was created under the default umask (0644 -> world-readable),
unlike .app_key/vault/integrations which are already 0600 via safe_chmod.

init_db() now chmods the SQLite file to 0600 right after create_all (POSIX
only; no-op on Windows, skipped for Postgres / in-memory). Unconditional and
idempotent, so it also re-locks already-deployed 0644 installs on next
startup. The transient rollback journal inherits 0600 from the parent file at
creation - no sidecar handling needed; -wal/-shm don't exist until WAL is
enabled (#4409 C4) and inherit the same mode then.

Satisfies Rule B, unblocking #4413 and the vault/integration secret moves.
Mirrors src/secret_storage.py:43-45.

Verified: security + DB-permission suites pass; 6 pre-existing visual_report
failures (missing markdown/nh3 deps) are unrelated.

Closes #4407

* fix(db): harden SQLite path parsing and re-lock sidecars

Address review feedback on #4420.

P2: derive the file to chmod from engine.url (SQLAlchemy's parsed URL)
via _sqlite_db_path(), instead of DATABASE_URL.replace("sqlite:///", "").
A driver-qualified URL (sqlite+pysqlite://) or one carrying query args
(?cache=shared) previously slipped past the prefix check / string slice
and left the DB world-readable; the parsed path resolves correctly and
drops the query.

P3: re-lock stale -wal/-shm/-journal sidecars to 0o600 at startup. The
main file is chmod'd first, so any sidecar SQLite creates afterward
inherits 0o600, but a -wal/-shm left world-readable by an older 0o644
install (once WAL was enabled) could still expose DB pages. Absent
sidecars are the normal case, not an error.

Tests: unit-test _sqlite_db_path across driver/query/memory/postgres URL
forms, and a subprocess test asserting stale 0o644 -wal/-shm are
re-locked on startup.

* fix(db): handle sqlite file URI app db permissions

* fix(db): close remaining SQLite permission bypasses

---------

Co-authored-by: Ethan <23321960+0xLeathery@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-07-11 21:15:49 +02:00
falabellamichael
c2d2075833 fix(stabilization): harden attachment lifecycle and agent guard signals (#5420)
* fix: harden stabilization attachment and agent guards

* fix(uploads): preserve durable references during cleanup

* fix(uploads): close cleanup and compaction races
2026-07-11 15:14:14 +01:00
RaresKeY
a02f8d8600 fix(llm): avoid blocking Kimi Code async header probes (#5231) 2026-07-11 15:06:15 +01:00
mashallow
d02565ce32 fix(markdown): stop currency dollars rendering as KaTeX inline math (#5132) 2026-07-11 14:45:57 +01:00
RaresKeY
524fa9dce2 fix: preserve pythonpath for built-in mcp servers (#5117) 2026-07-11 14:34:42 +01:00
RaresKeY
b3432873fb fix(email): clear bulk selection on context change (#5229) 2026-07-11 14:12:12 +01:00
Peter Karlsson
1c61c358cb fix(email): use UID commands instead of sequence numbers in IMAP fetches (#5149)
conn.search() / conn.fetch() operate on volatile positional sequence
numbers that shift whenever messages are deleted or expunged. Three call
sites in the sig-learner (_pull_headers, _fetch_bodies) and morning-brief
email section were storing these as "uid" and reusing them in subsequent
fetches — causing wrong-message returns or NO responses if another client
modified the mailbox concurrently.

Replaced with conn.uid("SEARCH", ...) / conn.uid("FETCH", ...), which use
persistent RFC 3501 UIDs. _scan_one (urgency action) already did this
correctly; these were the remaining callers.

The reproduction window is narrow (requires concurrent deletion between
search and fetch), so the fix is verified by regression tests rather than
manual end-to-end: _SpyImap raises AssertionError if conn.search() or
conn.fetch() are called instead of conn.uid().
2026-07-11 14:06:40 +01:00
jagadish-zentiti
410ad9a2fa fix(agent): cancel orphaned tool task when SSE client disconnects mid-call (#5106)
stream_agent_loop's per-tool drain loop had no cleanup path for early
generator close. Starlette throws GeneratorExit into the generator at
whatever await point it's suspended on when the SSE client disconnects
(aclose()) - here that's 'await _progress_q.get()' inside the drain
loop, before the final 'await _tool_task' line ever runs. The task,
which wraps execute_tool_block, was left running unawaited and
uncancelled.

For bash/python tools this orphans the underlying subprocess:
subprocess_tools.py already has correct CancelledError handling that
kills the child process, but only runs if the task is actually
cancelled. A client disconnecting mid long-running command left that
subprocess running server-side for its full duration with nothing
left to reap it.

Wrap the drain loop in try/finally: on early exit, cancel _tool_task
(if not already done) and await it so the existing subprocess-kill
path runs.

Adds a regression test that drives the real stream_agent_loop with a
fake tool handler, closes the generator mid tool-call (mirroring what
Starlette does on disconnect), and asserts the handler observed
cancellation immediately - not merely via asyncio.run()'s own
end-of-run task cleanup, which would mask the bug.

Fixes #5105
2026-07-11 13:44:06 +01:00
Wes Huber
c0b032fa10 fix(calendar): trust operator CA bundle in CalDAV test_connection (#4796)
* fix(calendar): trust operator CA bundle in CalDAV test_connection

The pre-flight test used httpx with trust_env=False, which ignored
SSL_CERT_FILE/REQUESTS_CA_BUNDLE. Self-signed CalDAV servers that
the real sync accepts (via caldav lib → requests → honors bundle)
were rejected by the test with CERTIFICATE_VERIFY_FAILED.

Build an explicit SSL context that loads the operator's CA bundle
and clears VERIFY_X509_STRICT (which rejects certs without a
keyUsage extension — common in self-signed setups). SSRF guards
(follow_redirects=False, trust_env=False) are preserved.

Fixes #4795
Fixes #4779

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(calendar): add regression tests and edge case handling for SSL context

Per review: add route-level regression tests covering SSL_CERT_FILE
precedence, VERIFY_X509_STRICT clearing, missing bundle graceful
fallback, and empty env var handling. Also log a warning when the
configured CA bundle path doesn't exist instead of silently falling
back to system CAs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(calendar): rewrite SSL tests to exercise route handler directly

Addresses review feedback: tests now use FastAPI TestClient to hit the
actual test_connection route, capturing the verify= kwarg passed to
httpx.AsyncClient. This ensures the route's SSL context construction
is covered, not a test-side duplicate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: retrigger CI (redirect hardening test is a CI-env flake, passes locally)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): remove module-level sys.modules stubs that leaked into other tests

The collection-time MagicMock stub of `caldav` replaced the real library
for every later test in the same process — test_caldav_redirect_hardening's
DAVClient became a mock that never sent the PROPFIND, failing its
must-reach-the-public-server assertion in CI. conftest already pre-imports
the real sqlalchemy/core.database, and the route's lazy imports are patched
per-request, so the stub block was both harmful and unnecessary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(calendar): verify exact CA bundle precedence

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-07-11 13:25:16 +01:00
tanmayraut45
c161866199 CalDAV: close the DAVClient on sync and write-back paths (#4793)
_sync_blocking (src/caldav_sync.py) and _writeback_blocking
(src/caldav_writeback.py) each open their own caldav.DAVClient via
_build_dav_client, but never close it. The client owns an HTTP session
with a pooled connection; without a close() that connection is held until
process exit.

Previously the fix added explicit client.close() calls before each early
return and at the end of the DB finally block. This still leaked the
client when SessionLocal() raised before the DB try/finally was entered.

Now _sync_blocking wraps the entire post-construction path in an outer
try/finally that calls client.close() unconditionally, covering:
  - AuthorizationError / NotFoundError early return
  - URL-fallback failure early return
  - no-calendars early return
  - normal return after sync
  - SessionLocal() construction failure (new regression coverage)

_writeback_blocking already used a try/finally (unchanged).

- src/caldav_sync.py: replace scattered client.close() calls with a
  single outer try/finally block around the discovery + DB sync path
- tests/test_caldav_client_cleanup.py: add CalendarDeletedEvent to the
  database stub; add regression test for SessionLocal() failure path

Closes #4593
2026-07-11 13:03:24 +01:00
red person
6725d1863c Skip vanished backup list entries (#2006) 2026-07-11 05:26:42 +01:00
jagadish-zentiti
99f0facc2c fix(mcp): guard DbTokenStorage against non-dict oauth_tokens JSON (#5107)
_load() returned whatever json.loads() produced without checking it was a
dict; _update() did the same before assigning data[key] = value. If the
oauth_tokens column ever held a JSON array or primitive (DB corruption,
manual edit, migration drift), _load()'s callers crashed with
AttributeError on .get(), and _update() crashed with TypeError trying to
item-assign into a list/string/int.

Validate the parsed value is a dict in both methods, falling back to {}
otherwise - same recovery behavior already used elsewhere in the codebase
for this exact JSON-blob-is-not-a-dict shape (_parse_tool_args,
_is_sensitive_path's siblings).

Adds 3 regression tests for _load, get_tokens, and _update against a
non-dict oauth_tokens value.

Fixes #5082
2026-07-11 05:26:23 +01:00
jagadish-zentiti
7a8f47e4ab fix(email): atomically claim scheduled emails before sending (#5110)
_scheduled_poll_once selected rows WHERE status='pending' and only wrote
status='sent'/'failed' after the SMTP send and IMAP append completed -
no atomic claim in between. Two overlapping callers (the in-process 30s
poller and an externally cron/systemd-driven 'odysseus-mail
poll-scheduled', or the CLI run manually) can both SELECT the same
pending row before either UPDATEs it, and both send it. _start_poller's
own docstring already names this exact risk ('avoid two copies of
_scheduled_poll_once racing on the same SQLite') but nothing in the code
enforced it - it was advisory only.

Add an atomic per-row claim: UPDATE ... SET status='sending' WHERE
id=? AND status='pending', proceeding only when rowcount == 1. The
loser of the race sees rowcount == 0 and skips the row instead of
sending a duplicate.

Adds a regression test that drives two real threads through the real
_scheduled_poll_once against a shared SQLite file, synchronized with a
barrier and a widened send-path window, and asserts exactly one send
fires. Reverting the fix makes the test fail reliably (5/5 runs); with
the fix it passes reliably (5/5 runs).

Fixes #5109
2026-07-11 04:23:36 +01:00
L1
a6c457f74e fix(email): never fall back to sequence-number IMAP ops for move/flag (#2732)
_store_email_flag and _move_email_message (used by the archive / delete / move /
mark-read endpoints) had an else branch that, when _uid_exists returned False,
ran conn.store(uid, ...) / conn.copy(uid, ...) followed by a folder-wide
conn.expunge(). But imaplib's plain store()/copy() take a message SEQUENCE
NUMBER, not a UID, so the op landed on whichever message occupied sequence
position == the UID value, and the expunge then permanently removed it. A stale
cached UID (or a server whose UID probe misbehaves) therefore deleted an
unrelated email instead of reporting 'not found'.

There is no valid case where treating a UID as a sequence number is correct, so
drop the fallback: when the UID isn't present, return False — callers already
surface 'Email not found'. Only the UID command path remains.

Sibling of #1874 (which fixes the auto-spam poller's _imap_move in
email_helpers.py); this covers the user-facing endpoints in email_routes.py.
Part of #2124.
2026-07-11 03:27:28 +01:00
Afonso Coutinho
a4e66bb59f fix: TTS available crashes on non-string tts_provider (#2034) 2026-07-11 03:19:51 +01:00
Afonso Coutinho
a6efea5486 fix: _matchesCombo crashes on non-string keybind from server (#2049) 2026-07-11 03:15:19 +01:00
Afonso Coutinho
565c69f40d fix: odysseus-memory cmd_add crashes on non-dict existing memory row (#2091) 2026-07-11 03:05:17 +01:00
Afonso Coutinho
06e038f00e fix: hwfit params_b/is_prequantized crash on non-string catalog fields (#2094) 2026-07-11 03:00:28 +01:00
Ashvin
0cb8db4de4 fix(tasks): scope manage_tasks mutations to an exact task owner (#5264)
The edit/delete/pause/run actions of do_manage_tasks gated ownership with
`if owner and task.owner and task.owner != owner`. The middle term made the
check a no-op whenever task.owner was null/empty — the state a scheduled task
sits in when it was created in no-login mode (or via the localhost middleware
bypass) before the periodic legacy-owner sweep reassigns it to the admin user.
Any authenticated user's agent could then edit, delete, pause, or run another
tenant's owner-less task; edit+run lets an attacker rewrite the task prompt and
execute it in the scheduler's agent context.

The sibling `list` action already scopes with an exact `owner == owner` filter,
so the mutators were strictly more permissive than the reader. Drop the middle
term so the guard fails closed on owner-less rows for authenticated callers,
matching `list` and the calendar/notes/gallery/session null-owner gates. Auth
disabled (owner falsy) and same-owner access are unchanged.
2026-07-11 01:45:14 +01:00
Boody
582a51054d Merge pull request #5313 from RaresKeY/fix/chat-web-search-explicit-deny
fix(chat): require explicit web search enable
2026-07-09 01:56:32 +03:00