mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 14:12:22 +01:00
Compare commits
13
Commits
d6882a895e
...
932b7f2446
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
932b7f2446 | ||
|
|
a58f526992 | ||
|
|
ed6cc88974 | ||
|
|
5198516979 | ||
|
|
095c74b985 | ||
|
|
34a3f8637a | ||
|
|
8449baea80 | ||
|
|
d58202d10e | ||
|
|
1209f258d7 | ||
|
|
d71284194b | ||
|
|
d458cade98 | ||
|
|
fe19d072e3 | ||
|
|
09565acc1e |
@@ -20,6 +20,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
@@ -31,6 +33,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
@@ -53,6 +57,7 @@ jobs:
|
|||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
# Detect whether this PR only touches documentation files.
|
# Detect whether this PR only touches documentation files.
|
||||||
# If so, skip the expensive pytest run while still reporting a passing check.
|
# If so, skip the expensive pytest run while still reporting a passing check.
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
@@ -32,6 +31,9 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: build (${{ matrix.arch }})
|
name: build (${{ matrix.arch }})
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -44,6 +46,8 @@ jobs:
|
|||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
@@ -78,8 +82,13 @@ jobs:
|
|||||||
name: merge manifest + tag
|
name: merge manifest + tag
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Read APP_VERSION + short sha
|
- name: Read APP_VERSION + short sha
|
||||||
id: ver
|
id: ver
|
||||||
run: |
|
run: |
|
||||||
@@ -116,6 +125,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||||
digests=$(printf "${REGISTRY}/${IMAGE_NAME}@sha256:%s " *)
|
digests=$(printf "${REGISTRY}/${IMAGE_NAME}@sha256:%s " *)
|
||||||
|
# word-splitting is intended: $tags and $digests each expand to multiple args
|
||||||
|
# shellcheck disable=SC2086
|
||||||
docker buildx imagetools create $tags $digests
|
docker buildx imagetools create $tags $digests
|
||||||
env:
|
env:
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ jobs:
|
|||||||
# Skip bots (Dependabot, release-drafter, etc.)
|
# Skip bots (Dependabot, release-drafter, etc.)
|
||||||
if: ${{ github.event.issue.user.type != 'Bot' }}
|
if: ${{ github.event.issue.user.type != 'Bot' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/scripts
|
sparse-checkout: .github/scripts
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: return require('./.github/scripts/check-issue-description.js')({github, context, core})
|
script: return require('./.github/scripts/check-issue-description.js')({github, context, core})
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
name: ci / PR checks
|
name: ci / PR checks
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
# pull_request_target runs in the base-repo context (has secrets) so the check
|
||||||
|
# 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]
|
||||||
|
|
||||||
# pull_request_target runs in the base-repo context (has secrets).
|
# Default-deny at the workflow level; each job opts into only the scopes it needs.
|
||||||
# The checkout below pins to the base branch so no fork code is executed.
|
# Note: modifying a PR's labels/comments needs pull-requests:write even though the
|
||||||
# The script only reads context.payload and calls the GitHub API.
|
# REST path is under /issues/{n}/...; issues:write alone returns 403 on PRs.
|
||||||
# Least privilege: contents:read for the base-ref checkout, pull-requests:write
|
permissions: {}
|
||||||
# to add/remove labels and post comments on PRs, and issues:write for the same
|
|
||||||
# on real issues. NOTE: modifying a *pull request's* labels/comments needs the
|
|
||||||
# `pull-requests` scope even though the REST path is under `/issues/{n}/...`;
|
|
||||||
# `issues:write` alone returns 403 on PRs.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-description:
|
check-description:
|
||||||
name: Check PR description
|
name: Check PR description
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
# Skip bots: they open PRs programmatically and have their own process.
|
# Skip bots: they open PRs programmatically and have their own process.
|
||||||
if: github.event.pull_request.user.type != 'Bot'
|
if: github.event.pull_request.user.type != 'Bot'
|
||||||
steps:
|
steps:
|
||||||
@@ -28,6 +27,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.base_ref }}
|
ref: ${{ github.base_ref }}
|
||||||
sparse-checkout: .github/scripts
|
sparse-checkout: .github/scripts
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
@@ -36,6 +36,7 @@ jobs:
|
|||||||
check-title:
|
check-title:
|
||||||
name: Check PR title (Conventional Commits)
|
name: Check PR title (Conventional Commits)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions: {}
|
||||||
# Skip bots: they open PRs programmatically and have their own process.
|
# Skip bots: they open PRs programmatically and have their own process.
|
||||||
if: github.event.pull_request.user.type != 'Bot'
|
if: github.event.pull_request.user.type != 'Bot'
|
||||||
steps:
|
steps:
|
||||||
@@ -59,6 +60,9 @@ jobs:
|
|||||||
check-mergeable:
|
check-mergeable:
|
||||||
name: Flag unmergeable PRs
|
name: Flag unmergeable PRs
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
# Skip bots: they open PRs programmatically and have their own process.
|
# Skip bots: they open PRs programmatically and have their own process.
|
||||||
if: github.event.pull_request.user.type != 'Bot'
|
if: github.event.pull_request.user.type != 'Bot'
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -333,6 +333,12 @@ To expose Odysseus on a local network or Tailscale with HTTPS:
|
|||||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
| `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). |
|
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||||
|
|
||||||
|
### Outlook / Office 365 email
|
||||||
|
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
||||||
|
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
||||||
|
passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
|
||||||
|
current limitation and the planned integration direction.
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Outlook / Office 365 email accounts
|
||||||
|
|
||||||
|
Odysseus email accounts currently use IMAP and SMTP with username/password
|
||||||
|
authentication. That works for providers that still allow app passwords or
|
||||||
|
mailbox passwords for IMAP/SMTP.
|
||||||
|
|
||||||
|
Microsoft disables basic authentication for Outlook and Microsoft 365 in most
|
||||||
|
modern accounts and tenants. If you try to add an Outlook account with a normal
|
||||||
|
password, Microsoft may return errors such as:
|
||||||
|
|
||||||
|
- `IMAP: AUTHENTICATE failed`
|
||||||
|
- `SMTP: 535 5.7.139 Authentication unsuccessful, basic authentication is disabled`
|
||||||
|
|
||||||
|
This is expected. Odysseus does not support Microsoft OAuth or Graph Mail yet,
|
||||||
|
so Outlook / Office 365 accounts cannot currently be added through the password
|
||||||
|
form. Use another email provider with app-password support, or track the future
|
||||||
|
Microsoft Graph OAuth integration.
|
||||||
@@ -245,10 +245,27 @@ def _imap_connect(account: str | None = None):
|
|||||||
timeout=EMAIL_SOCKET_TIMEOUT,
|
timeout=EMAIL_SOCKET_TIMEOUT,
|
||||||
)
|
)
|
||||||
if cfg["imap_starttls"]:
|
if cfg["imap_starttls"]:
|
||||||
conn.starttls()
|
try:
|
||||||
|
conn.starttls()
|
||||||
|
except Exception:
|
||||||
|
# Don't leak the open plain socket on a rejected STARTTLS. (#3174)
|
||||||
|
try:
|
||||||
|
conn.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
if getattr(conn, "sock", None):
|
if getattr(conn, "sock", None):
|
||||||
conn.sock.settimeout(EMAIL_SOCKET_TIMEOUT)
|
conn.sock.settimeout(EMAIL_SOCKET_TIMEOUT)
|
||||||
conn.login(cfg["imap_user"], cfg["imap_password"])
|
try:
|
||||||
|
conn.login(cfg["imap_user"], cfg["imap_password"])
|
||||||
|
except Exception:
|
||||||
|
# A failed login otherwise orphans the connected socket; close it
|
||||||
|
# before propagating (shutdown() is the pre-auth low-level close). (#3174)
|
||||||
|
try:
|
||||||
|
conn.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
@@ -778,7 +795,16 @@ def _smtp_connect(account=None, cfg=None):
|
|||||||
port,
|
port,
|
||||||
timeout=EMAIL_SOCKET_TIMEOUT,
|
timeout=EMAIL_SOCKET_TIMEOUT,
|
||||||
)
|
)
|
||||||
conn.starttls()
|
try:
|
||||||
|
conn.starttls()
|
||||||
|
except Exception:
|
||||||
|
# Don't leak the open plain socket on a rejected STARTTLS. SMTP has
|
||||||
|
# no shutdown(); close() is the low-level socket close (no QUIT). (#3174)
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
elif security == "ssl":
|
elif security == "ssl":
|
||||||
conn = smtplib.SMTP_SSL(
|
conn = smtplib.SMTP_SSL(
|
||||||
cfg["smtp_host"],
|
cfg["smtp_host"],
|
||||||
@@ -792,7 +818,16 @@ def _smtp_connect(account=None, cfg=None):
|
|||||||
timeout=EMAIL_SOCKET_TIMEOUT,
|
timeout=EMAIL_SOCKET_TIMEOUT,
|
||||||
)
|
)
|
||||||
if cfg["smtp_user"] and cfg["smtp_password"]:
|
if cfg["smtp_user"] and cfg["smtp_password"]:
|
||||||
conn.login(cfg["smtp_user"], cfg["smtp_password"])
|
try:
|
||||||
|
conn.login(cfg["smtp_user"], cfg["smtp_password"])
|
||||||
|
except Exception:
|
||||||
|
# A failed login otherwise orphans the connected socket; close it
|
||||||
|
# before propagating (SMTP has no shutdown(); close() = socket close). (#3174)
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -155,22 +155,30 @@ def setup_api_token_routes() -> APIRouter:
|
|||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
payload = {}
|
payload = {}
|
||||||
scope_list = _normalize_scopes(payload.get("scopes"))
|
|
||||||
scopes_value = ",".join(scope_list)
|
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(404, "Token not found")
|
raise HTTPException(404, "Token not found")
|
||||||
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
||||||
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
||||||
token.scopes = scopes_value
|
# Only touch scopes when the caller actually sent them. A partial
|
||||||
|
# update such as a rename ({"name": ...} with no "scopes" key) must
|
||||||
|
# not silently reset the token to the default scope — that dropped
|
||||||
|
# every previously granted scope.
|
||||||
|
if "scopes" in payload:
|
||||||
|
token.scopes = ",".join(_normalize_scopes(payload.get("scopes")))
|
||||||
db.add(token)
|
db.add(token)
|
||||||
|
current_scopes = [
|
||||||
|
s.strip()
|
||||||
|
for s in (getattr(token, "scopes", "") or DEFAULT_SCOPES).split(",")
|
||||||
|
if s.strip()
|
||||||
|
]
|
||||||
response = {
|
response = {
|
||||||
"id": token_id,
|
"id": token_id,
|
||||||
"name": getattr(token, "name", ""),
|
"name": getattr(token, "name", ""),
|
||||||
"owner": getattr(token, "owner", None),
|
"owner": getattr(token, "owner", None),
|
||||||
"token_prefix": getattr(token, "token_prefix", ""),
|
"token_prefix": getattr(token, "token_prefix", ""),
|
||||||
"scopes": scope_list,
|
"scopes": current_scopes,
|
||||||
}
|
}
|
||||||
_invalidate_cache(request)
|
_invalidate_cache(request)
|
||||||
return response
|
return response
|
||||||
|
|||||||
+54
-2
@@ -71,6 +71,38 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
|
|||||||
smtp.sendmail(from_addr, recipients, message)
|
smtp.sendmail(from_addr, recipients, message)
|
||||||
|
|
||||||
|
|
||||||
|
def _friendly_email_auth_error(protocol: str, host: str, error: object) -> str:
|
||||||
|
"""Return a clearer setup error for known provider auth policies."""
|
||||||
|
raw = str(error or "")
|
||||||
|
lower = raw.lower()
|
||||||
|
host_lower = (host or "").lower()
|
||||||
|
microsoft_host = any(
|
||||||
|
marker in host_lower
|
||||||
|
for marker in (
|
||||||
|
"outlook.office365.com",
|
||||||
|
"smtp.office365.com",
|
||||||
|
"office365.com",
|
||||||
|
"outlook.com",
|
||||||
|
"hotmail.com",
|
||||||
|
"live.com",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
microsoft_basic_auth_failure = (
|
||||||
|
"5.7.139" in lower
|
||||||
|
or "basic authentication is disabled" in lower
|
||||||
|
or ("authenticate failed" in lower and microsoft_host)
|
||||||
|
or ("authentication unsuccessful" in lower and microsoft_host)
|
||||||
|
)
|
||||||
|
if microsoft_basic_auth_failure:
|
||||||
|
return (
|
||||||
|
"Microsoft no longer accepts normal mailbox passwords for "
|
||||||
|
"Outlook/Office 365 IMAP/SMTP in most accounts. Odysseus "
|
||||||
|
"does not support Microsoft OAuth/Graph mail yet, so Outlook "
|
||||||
|
"accounts cannot be added with this password form."
|
||||||
|
)
|
||||||
|
return raw[:200]
|
||||||
|
|
||||||
|
|
||||||
def _strip_think(text: str) -> str:
|
def _strip_think(text: str) -> str:
|
||||||
"""Email-flavored think strip — thin wrapper over the central helper.
|
"""Email-flavored think strip — thin wrapper over the central helper.
|
||||||
|
|
||||||
@@ -706,7 +738,16 @@ def _open_imap_connection(host: str, port: int, *, starttls: bool, timeout: int
|
|||||||
port = int(port or 993)
|
port = int(port or 993)
|
||||||
if starttls:
|
if starttls:
|
||||||
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
||||||
conn.starttls()
|
try:
|
||||||
|
conn.starttls()
|
||||||
|
except Exception:
|
||||||
|
# Don't leak the open plain socket if the STARTTLS upgrade is
|
||||||
|
# rejected; close it before propagating. (#3174)
|
||||||
|
try:
|
||||||
|
conn.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
elif port == 993:
|
elif port == 993:
|
||||||
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout)
|
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout)
|
||||||
else:
|
else:
|
||||||
@@ -739,7 +780,18 @@ def _imap_connect(account_id: str | None = None, owner: str = ""):
|
|||||||
starttls=bool(cfg.get("imap_starttls")),
|
starttls=bool(cfg.get("imap_starttls")),
|
||||||
timeout=_IMAP_TIMEOUT_SECONDS,
|
timeout=_IMAP_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
conn.login(cfg["imap_user"], cfg["imap_password"])
|
try:
|
||||||
|
conn.login(cfg["imap_user"], cfg["imap_password"])
|
||||||
|
except Exception:
|
||||||
|
# A failed AUTHENTICATE (e.g. an Office 365 app password on an
|
||||||
|
# MFA-enabled tenant, #3174) otherwise orphans the already-connected
|
||||||
|
# socket; close it before propagating so a misconfigured account
|
||||||
|
# can't leak one descriptor per retry / background poller pass.
|
||||||
|
try:
|
||||||
|
conn.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from routes.email_helpers import (
|
|||||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||||
_fetch_sender_thread_context, _pre_retrieve_context,
|
_fetch_sender_thread_context, _pre_retrieve_context,
|
||||||
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
||||||
|
_friendly_email_auth_error,
|
||||||
SendEmailRequest, ExtractStyleRequest,
|
SendEmailRequest, ExtractStyleRequest,
|
||||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||||
attachment_extract_dir, _email_cache_owner_clause,
|
attachment_extract_dir, _email_cache_owner_clause,
|
||||||
@@ -3163,7 +3164,7 @@ def setup_email_routes():
|
|||||||
try: conn.logout()
|
try: conn.logout()
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
imap_result = {"ok": False, "error": str(e)[:200]}
|
imap_result = {"ok": False, "error": _friendly_email_auth_error("IMAP", imap_host, e)}
|
||||||
|
|
||||||
smtp_host = (body.get("smtp_host") or "").strip()
|
smtp_host = (body.get("smtp_host") or "").strip()
|
||||||
if smtp_host:
|
if smtp_host:
|
||||||
@@ -3185,7 +3186,7 @@ def setup_email_routes():
|
|||||||
try: smtp.quit()
|
try: smtp.quit()
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
smtp_result = {"ok": False, "error": str(e)[:200]}
|
smtp_result = {"ok": False, "error": _friendly_email_auth_error("SMTP", smtp_host, e)}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
|
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
|
||||||
|
|||||||
@@ -490,7 +490,13 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
# Copy messages up to keep_count
|
# Copy messages up to keep_count
|
||||||
msgs_to_copy = source.history[:keep_count]
|
msgs_to_copy = source.history[:keep_count]
|
||||||
for msg in msgs_to_copy:
|
for msg in msgs_to_copy:
|
||||||
new_session.add_message(ChatMessage(msg.role, msg.content, msg.metadata))
|
# Copy the metadata dict. Sharing it would let the fork's
|
||||||
|
# persistence (add_message -> _persist_message stamps
|
||||||
|
# _db_id/timestamp onto the dict) mutate the SOURCE session's
|
||||||
|
# in-memory messages, corrupting their _db_id and breaking
|
||||||
|
# edit/delete-by-id on the original conversation.
|
||||||
|
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
|
||||||
|
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
|
||||||
try:
|
try:
|
||||||
from src.event_bus import fire_event
|
from src.event_bus import fire_event
|
||||||
fire_event("session_created", getattr(source, 'owner', None))
|
fire_event("session_created", getattr(source, 'owner', None))
|
||||||
|
|||||||
@@ -1232,8 +1232,9 @@ def setup_model_routes(model_discovery):
|
|||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.error('Auth gate error in GET /api/models, failing closed: %s', e)
|
||||||
|
raise HTTPException(status_code=500, detail='Internal error')
|
||||||
# Admins see every endpoint (they manage the global pool); regular
|
# Admins see every endpoint (they manage the global pool); regular
|
||||||
# users get the owner-scoped view.
|
# users get the owner-scoped view.
|
||||||
_is_admin = False
|
_is_admin = False
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from src.request_models import PresetUpdateRequest
|
from src.request_models import PresetUpdateRequest
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
from src.auth_helpers import effective_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -100,7 +101,8 @@ def setup_preset_routes(preset_manager) -> APIRouter:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
model_spec = data.get("model") or ""
|
model_spec = data.get("model") or ""
|
||||||
url, model, headers = _resolve_model(model_spec)
|
user = effective_user(request)
|
||||||
|
url, model, headers = _resolve_model(model_spec, owner=user)
|
||||||
result = await llm_call_async(url, model, messages, temperature=0.8, max_tokens=500, headers=headers)
|
result = await llm_call_async(url, model, messages, temperature=0.8, max_tokens=500, headers=headers)
|
||||||
return {"success": True, "prompt": result.strip()}
|
return {"success": True, "prompt": result.strip()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1020,7 +1020,7 @@ def _resolve_audit_models(owner=None):
|
|||||||
spec = (get_setting("teacher_model", "") or "").strip()
|
spec = (get_setting("teacher_model", "") or "").strip()
|
||||||
if spec:
|
if spec:
|
||||||
from src.ai_interaction import _resolve_model
|
from src.ai_interaction import _resolve_model
|
||||||
t_url, t_model, t_headers = _resolve_model(spec)
|
t_url, t_model, t_headers = _resolve_model(spec, owner=owner)
|
||||||
if t_url and t_model:
|
if t_url and t_model:
|
||||||
teacher = (t_url, t_model, t_headers)
|
teacher = (t_url, t_model, t_headers)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -236,6 +236,43 @@ def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) ->
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_extraction_json(raw: str) -> list:
|
||||||
|
"""Parse the extraction LLM's reply into a list of facts, tolerating
|
||||||
|
reasoning-model noise.
|
||||||
|
|
||||||
|
The model emits <think>…</think> (and sometimes a prose preamble or a
|
||||||
|
```json fence) AROUND the JSON array; without stripping it, json.loads
|
||||||
|
bombs and the run silently yields "0 candidates". Pure str -> list (no
|
||||||
|
LLM/network); returns [] on any parse failure instead of raising.
|
||||||
|
"""
|
||||||
|
text = (raw or "").strip()
|
||||||
|
try:
|
||||||
|
from src.text_helpers import strip_think as _strip_think
|
||||||
|
text = _strip_think(text, prose=True, prompt_echo=True).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||||
|
# JSON may still be embedded in surrounding commentary (leading prose or
|
||||||
|
# trailing remarks like "[...] Done!") — slice from the first '[' to the
|
||||||
|
# last ']' whenever both exist. Slice unconditionally: a reply that starts
|
||||||
|
# with '[' can still carry trailing commentary that breaks json.loads.
|
||||||
|
_start = text.find("[")
|
||||||
|
_end = text.rfind("]")
|
||||||
|
if 0 <= _start < _end:
|
||||||
|
text = text[_start : _end + 1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
facts = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.debug("Memory extraction returned non-JSON: %r", (raw or "")[:120])
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Memory extraction returned non-JSON: %r", (raw or "")[:120])
|
||||||
|
return []
|
||||||
|
return facts if isinstance(facts, list) else []
|
||||||
|
|
||||||
|
|
||||||
async def extract_and_store(
|
async def extract_and_store(
|
||||||
session,
|
session,
|
||||||
memory_manager,
|
memory_manager,
|
||||||
@@ -284,9 +321,34 @@ async def extract_and_store(
|
|||||||
|
|
||||||
fallback_facts = _fallback_memory_candidates(stripped_recent)
|
fallback_facts = _fallback_memory_candidates(stripped_recent)
|
||||||
|
|
||||||
|
# Flatten the window into a SINGLE user message instead of appending the
|
||||||
|
# raw alternating role messages. Passed as raw chat messages, the model
|
||||||
|
# treats the window as a conversation to CONTINUE rather than a transcript
|
||||||
|
# to ANALYZE, so it reliably extracts nothing — typically returning `[]`
|
||||||
|
# (and, depending on the input, sometimes an empty or <think>-only
|
||||||
|
# completion when the window ends on an assistant turn). This was the real
|
||||||
|
# cause of auto-memory logging "0 candidates" on every run. Reframing it as
|
||||||
|
# one "analyze this transcript, return the JSON array" user message makes
|
||||||
|
# the model actually extract. Controlled repro on this model: 0/6 trials
|
||||||
|
# with the old structure vs 6/6 with this one. The skill extractor flattens
|
||||||
|
# for the same reason.
|
||||||
|
def _flatten_msg(m):
|
||||||
|
c = m.get("content", "")
|
||||||
|
if isinstance(c, list):
|
||||||
|
c = " ".join(
|
||||||
|
b.get("text", "") for b in c
|
||||||
|
if isinstance(b, dict) and b.get("type") == "text"
|
||||||
|
)
|
||||||
|
return f"{m.get('role', '?')}: {c}"
|
||||||
|
|
||||||
|
transcript = "\n\n".join(_flatten_msg(m) for m in stripped_recent)
|
||||||
extraction_messages = [
|
extraction_messages = [
|
||||||
{"role": "system", "content": EXTRACT_SYSTEM_PROMPT},
|
{"role": "system", "content": EXTRACT_SYSTEM_PROMPT},
|
||||||
] + stripped_recent
|
{"role": "user", "content": (
|
||||||
|
"Conversation to analyze:\n\n" + transcript
|
||||||
|
+ "\n\nReturn the JSON array of durable facts now (or [] if none)."
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
|
||||||
facts = []
|
facts = []
|
||||||
try:
|
try:
|
||||||
@@ -295,19 +357,20 @@ async def extract_and_store(
|
|||||||
model,
|
model,
|
||||||
extraction_messages,
|
extraction_messages,
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=500,
|
# A reasoning model spends most of its budget on <think> tokens
|
||||||
|
# BEFORE emitting the JSON, so the old 500 truncated the response
|
||||||
|
# before any JSON appeared → every run logged "0 candidates". The
|
||||||
|
# audit path hit the same wall and raised to 16384; extraction's
|
||||||
|
# output (a short facts list) is small, so an ample ceiling is
|
||||||
|
# enough once thinking has room.
|
||||||
|
max_tokens=4096,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse JSON from response (handle markdown fences if model wraps them)
|
# Parse JSON, tolerating reasoning-model noise (<think> blocks, a
|
||||||
text = raw.strip()
|
# ```json fence, and leading/trailing commentary). See
|
||||||
if text.startswith("```"):
|
# _parse_extraction_json — returns [] rather than raising.
|
||||||
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
facts = _parse_extraction_json(raw)
|
||||||
|
|
||||||
try:
|
|
||||||
facts = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.debug("Memory extraction returned non-JSON")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"LLM memory extraction failed; using fallback candidates if available: {e}")
|
logger.warning(f"LLM memory extraction failed; using fallback candidates if available: {e}")
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -168,7 +168,6 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
drop_items = decision.get("drop") if isinstance(decision, dict) else None
|
drop_items = decision.get("drop") if isinstance(decision, dict) else None
|
||||||
if isinstance(keep_items, list) and isinstance(drop_items, list):
|
if isinstance(keep_items, list) and isinstance(drop_items, list):
|
||||||
by_id = {m.get("id"): m for m in group_memories if m.get("id")}
|
by_id = {m.get("id"): m for m in group_memories if m.get("id")}
|
||||||
keep_ids = set()
|
|
||||||
cleaned_by_id = {}
|
cleaned_by_id = {}
|
||||||
for item in keep_items:
|
for item in keep_items:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
@@ -179,7 +178,6 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
text = (item.get("text") or "").strip()
|
text = (item.get("text") or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
keep_ids.add(mid)
|
|
||||||
cleaned = {
|
cleaned = {
|
||||||
"category": (item.get("category") or by_id[mid].get("category") or "fact").strip(),
|
"category": (item.get("category") or by_id[mid].get("category") or "fact").strip(),
|
||||||
}
|
}
|
||||||
@@ -188,11 +186,20 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
cleaned["text"] = text
|
cleaned["text"] = text
|
||||||
cleaned_by_id[mid] = cleaned
|
cleaned_by_id[mid] = cleaned
|
||||||
|
|
||||||
# If the model only saw a truncated memory, do not let
|
# Delete only memories the model EXPLICITLY dropped, never
|
||||||
# that partial view delete or rewrite the full memory.
|
# ones it merely omitted from `keep`. Treating the
|
||||||
keep_ids.update(mid for mid in truncated_ids if mid in by_id)
|
# complement of `keep` as deletions meant a model that
|
||||||
|
# forgot to re-list an id (common) silently destroyed that
|
||||||
|
# memory. Honor the explicit `drop` set instead.
|
||||||
|
drop_ids = {
|
||||||
|
d.get("id")
|
||||||
|
for d in drop_items
|
||||||
|
if isinstance(d, dict) and d.get("id") in by_id
|
||||||
|
}
|
||||||
|
# Never delete a memory the model only saw truncated.
|
||||||
|
drop_ids -= truncated_ids
|
||||||
|
|
||||||
if keep_ids:
|
if drop_ids or cleaned_by_id:
|
||||||
changed_text = 0
|
changed_text = 0
|
||||||
group_ref_ids = {id(m) for m in group_memories}
|
group_ref_ids = {id(m) for m in group_memories}
|
||||||
kept_all = []
|
kept_all = []
|
||||||
@@ -201,7 +208,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
kept_all.append(mem)
|
kept_all.append(mem)
|
||||||
continue
|
continue
|
||||||
mid = mem.get("id")
|
mid = mem.get("id")
|
||||||
if mid not in keep_ids:
|
if mid in drop_ids:
|
||||||
continue
|
continue
|
||||||
cleaned = cleaned_by_id.get(mid) or {}
|
cleaned = cleaned_by_id.get(mid) or {}
|
||||||
if mid in truncated_ids:
|
if mid in truncated_ids:
|
||||||
@@ -213,7 +220,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
mem["category"] = cleaned["category"]
|
mem["category"] = cleaned["category"]
|
||||||
kept_all.append(mem)
|
kept_all.append(mem)
|
||||||
|
|
||||||
removed = len(group_memories) - len(keep_ids)
|
removed = sum(1 for m in group_memories if m.get("id") in drop_ids)
|
||||||
total_scanned += len(group_memories)
|
total_scanned += len(group_memories)
|
||||||
if removed or changed_text:
|
if removed or changed_text:
|
||||||
all_memories = kept_all
|
all_memories = kept_all
|
||||||
|
|||||||
+33
-11
@@ -242,6 +242,20 @@ def _build_dav_client(url: str, username: str, password: str):
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _should_prune_window(seen_uids: set, parse_failed: bool) -> bool:
|
||||||
|
"""Whether the post-sync prune of vanished CalDAV events is safe to run.
|
||||||
|
|
||||||
|
The prune deletes local ``origin=="caldav"`` rows in the window whose UID the
|
||||||
|
server did not just return. Any parse failure (total or partial) makes
|
||||||
|
``seen_uids`` an incomplete view of the server, so pruning against it can
|
||||||
|
delete events that still exist upstream but could not be read: a total
|
||||||
|
failure wipes the whole window, a partial failure deletes just the
|
||||||
|
unreadable ones. Only prune on a clean read. An empty ``seen_uids`` after a
|
||||||
|
clean read is a genuinely empty window, which is safe to prune.
|
||||||
|
"""
|
||||||
|
return not parse_failed
|
||||||
|
|
||||||
|
|
||||||
def _sync_blocking(owner: str, url: str, username: str, password: str, account_id: str = "") -> dict:
|
def _sync_blocking(owner: str, url: str, username: str, password: str, account_id: str = "") -> dict:
|
||||||
"""The actual sync — synchronous, intended to run in a threadpool.
|
"""The actual sync — synchronous, intended to run in a threadpool.
|
||||||
Returns counts: {calendars, events, deleted, errors}."""
|
Returns counts: {calendars, events, deleted, errors}."""
|
||||||
@@ -328,6 +342,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
# duplicate UIDs within the same batch are updated, not re-inserted
|
# duplicate UIDs within the same batch are updated, not re-inserted
|
||||||
# (which would violate the UNIQUE constraint on commit).
|
# (which would violate the UNIQUE constraint on commit).
|
||||||
pending: dict = {}
|
pending: dict = {}
|
||||||
|
parse_failed = False
|
||||||
try:
|
try:
|
||||||
objs = remote_cal.date_search(start=start, end=end, expand=False)
|
objs = remote_cal.date_search(start=start, end=end, expand=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -339,6 +354,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
ical = iCal.from_ical(obj.data)
|
ical = iCal.from_ical(obj.data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["errors"].append(f"{display_name}: parse failed ({e})")
|
result["errors"].append(f"{display_name}: parse failed ({e})")
|
||||||
|
parse_failed = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for comp in ical.walk():
|
for comp in ical.walk():
|
||||||
@@ -415,17 +431,23 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
# are prunable; locally-created events (agent / email triage / a
|
# are prunable; locally-created events (agent / email triage / a
|
||||||
# UI event whose write-back failed) carry origin NULL and must
|
# UI event whose write-back failed) carry origin NULL and must
|
||||||
# never be deleted just because the server didn't return them.
|
# never be deleted just because the server didn't return them.
|
||||||
stale = db.query(CalendarEvent).filter(
|
# Skip the prune on any parse failure: seen_uids is then an
|
||||||
CalendarEvent.calendar_id == local_cal.id,
|
# incomplete view of the server, so pruning against it would
|
||||||
CalendarEvent.origin == "caldav",
|
# delete events that still exist upstream but could not be read
|
||||||
CalendarEvent.dtstart >= start,
|
# (the empty-seen_uids case wipes the whole window; a partial
|
||||||
CalendarEvent.dtstart <= end,
|
# failure deletes just the unreadable rows).
|
||||||
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
|
if _should_prune_window(seen_uids, parse_failed):
|
||||||
).all()
|
stale = db.query(CalendarEvent).filter(
|
||||||
for ev in stale:
|
CalendarEvent.calendar_id == local_cal.id,
|
||||||
db.delete(ev)
|
CalendarEvent.origin == "caldav",
|
||||||
result["deleted"] += len(stale)
|
CalendarEvent.dtstart >= start,
|
||||||
db.commit()
|
CalendarEvent.dtstart <= end,
|
||||||
|
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
|
||||||
|
).all()
|
||||||
|
for ev in stale:
|
||||||
|
db.delete(ev)
|
||||||
|
result["deleted"] += len(stale)
|
||||||
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("CalDAV sync failed for one calendar")
|
logger.exception("CalDAV sync failed for one calendar")
|
||||||
result["errors"].append(str(e)[:200])
|
result["errors"].append(str(e)[:200])
|
||||||
|
|||||||
@@ -115,9 +115,12 @@ Use precise language. Show causal relationships explicitly. Quantify uncertainty
|
|||||||
def save(self, presets: Dict[str, Any]) -> bool:
|
def save(self, presets: Dict[str, Any]) -> bool:
|
||||||
"""Save presets to file"""
|
"""Save presets to file"""
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(self.presets_file), exist_ok=True)
|
# Atomic write (tmp file + os.replace) so a crash or serialization
|
||||||
with open(self.presets_file, 'w', encoding="utf-8") as f:
|
# error mid-write can't truncate presets.json and lose every saved
|
||||||
json.dump(presets, f, indent=2)
|
# preset. Lazy import keeps this module free of the heavy core
|
||||||
|
# package import graph at load time.
|
||||||
|
from core.atomic_io import atomic_write_json
|
||||||
|
atomic_write_json(self.presets_file, presets, indent=2)
|
||||||
self.presets = presets
|
self.presets = presets
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -229,12 +229,13 @@ portable across users / hosts.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def _call_teacher(teacher_model_spec: str, prompt: str) -> Optional[str]:
|
async def _call_teacher(teacher_model_spec: str, prompt: str,
|
||||||
|
owner: Optional[str] = None) -> Optional[str]:
|
||||||
"""Call the configured teacher endpoint with the escalation prompt."""
|
"""Call the configured teacher endpoint with the escalation prompt."""
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
from src.ai_interaction import _resolve_model, _TEACHER_SYSTEM_PROMPT
|
from src.ai_interaction import _resolve_model, _TEACHER_SYSTEM_PROMPT
|
||||||
try:
|
try:
|
||||||
url, model, headers = _resolve_model(teacher_model_spec)
|
url, model, headers = _resolve_model(teacher_model_spec, owner=owner)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"teacher endpoint not resolvable ({teacher_model_spec!r}): {e}")
|
logger.warning(f"teacher endpoint not resolvable ({teacher_model_spec!r}): {e}")
|
||||||
return None
|
return None
|
||||||
@@ -388,7 +389,7 @@ async def escalate_and_learn(
|
|||||||
untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD,
|
untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD,
|
||||||
trace=_format_trace(tool_results, agent_reply),
|
trace=_format_trace(tool_results, agent_reply),
|
||||||
)
|
)
|
||||||
response = await _call_teacher(teacher_spec, prompt)
|
response = await _call_teacher(teacher_spec, prompt, owner=owner)
|
||||||
if not response:
|
if not response:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -523,7 +524,7 @@ async def run_teacher_inline(
|
|||||||
# Resolve teacher endpoint
|
# Resolve teacher endpoint
|
||||||
try:
|
try:
|
||||||
from src.ai_interaction import _resolve_model
|
from src.ai_interaction import _resolve_model
|
||||||
teacher_url, teacher_model, teacher_headers = _resolve_model(teacher_spec)
|
teacher_url, teacher_model, teacher_headers = _resolve_model(teacher_spec, owner=owner)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"teacher endpoint not resolvable ({teacher_spec!r}): {e}")
|
logger.warning(f"teacher endpoint not resolvable ({teacher_spec!r}): {e}")
|
||||||
yield (
|
yield (
|
||||||
@@ -617,7 +618,7 @@ async def run_teacher_inline(
|
|||||||
untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD,
|
untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD,
|
||||||
trace=_format_trace(captured_tool_events, teacher_text),
|
trace=_format_trace(captured_tool_events, teacher_text),
|
||||||
)
|
)
|
||||||
skill_response = await _call_teacher(teacher_spec, prompt)
|
skill_response = await _call_teacher(teacher_spec, prompt, owner=owner)
|
||||||
if skill_response and "NO_SKILL" in skill_response and not _extract_skill_json(skill_response):
|
if skill_response and "NO_SKILL" in skill_response and not _extract_skill_json(skill_response):
|
||||||
logger.info("teacher declined to write a skill (NO_SKILL)")
|
logger.info("teacher declined to write a skill (NO_SKILL)")
|
||||||
yield (
|
yield (
|
||||||
|
|||||||
+1
-18
@@ -432,8 +432,7 @@ async function loadEndpoints() {
|
|||||||
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
|
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
|
||||||
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
|
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-ep-actions">
|
<div style="display:flex;gap:4px;align-items:center;">
|
||||||
${_isLocalEndpoint(ep.base_url) ? '<select class="admin-tools-select" data-adm-tools-select="' + ep.id + '" title="Native tool calling mode. Auto = use heuristic (fenced blocks for Ollama /v1). On = always use native function calling. Off = always use fenced blocks."><option value="auto"' + (ep.supports_tools !== true && ep.supports_tools !== false ? ' selected' : '') + '>Tools: Auto</option><option value="true"' + (ep.supports_tools === true ? ' selected' : '') + '>Tools: On</option><option value="false"' + (ep.supports_tools === false ? ' selected' : '') + '>Tools: Off</option></select>' : ''}
|
|
||||||
<button class="admin-btn-sm" data-adm-toggle-ep="${ep.id}">${ep.is_enabled ? 'Disable' : 'Enable'}</button>
|
<button class="admin-btn-sm" data-adm-toggle-ep="${ep.id}">${ep.is_enabled ? 'Disable' : 'Enable'}</button>
|
||||||
<button class="admin-btn-delete" data-adm-del-ep="${ep.id}" data-adm-ep-online="${ep.online ? '1' : '0'}">Delete</button>
|
<button class="admin-btn-delete" data-adm-del-ep="${ep.id}" data-adm-ep-online="${ep.online ? '1' : '0'}">Delete</button>
|
||||||
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}
|
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}
|
||||||
@@ -478,22 +477,6 @@ async function loadEndpoints() {
|
|||||||
queryAll('[data-adm-toggle-ep]').forEach(btn => {
|
queryAll('[data-adm-toggle-ep]').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); });
|
btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); });
|
||||||
});
|
});
|
||||||
queryAll('[data-adm-tools-select]').forEach(sel => {
|
|
||||||
sel.addEventListener('change', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const epId = sel.dataset.admToolsSelect;
|
|
||||||
const val = sel.value;
|
|
||||||
const body = {};
|
|
||||||
if (val === 'auto') body.supports_tools = null;
|
|
||||||
else body.supports_tools = val === 'true';
|
|
||||||
await fetch(`/api/model-endpoints/${epId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
loadEndpoints();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
queryAll('[data-adm-copy-url]').forEach(btn => {
|
queryAll('[data-adm-copy-url]').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -35,11 +35,6 @@ function _pushRecent(mid) {
|
|||||||
next.unshift(mid);
|
next.unshift(mid);
|
||||||
_saveList(RECENT_KEY, next.slice(0, RECENT_MAX));
|
_saveList(RECENT_KEY, next.slice(0, RECENT_MAX));
|
||||||
}
|
}
|
||||||
function _removeRecent(mid) {
|
|
||||||
if (!mid) return;
|
|
||||||
const next = _loadRecent().filter(x => x !== mid);
|
|
||||||
_saveList(RECENT_KEY, next);
|
|
||||||
}
|
|
||||||
function _loadFavorites() { return _loadList(FAVORITES_KEY); }
|
function _loadFavorites() { return _loadList(FAVORITES_KEY); }
|
||||||
function _toggleFavorite(mid) {
|
function _toggleFavorite(mid) {
|
||||||
const favs = _loadFavorites();
|
const favs = _loadFavorites();
|
||||||
@@ -309,7 +304,7 @@ function _initModelPickerDropdown() {
|
|||||||
empty.textContent = text;
|
empty.textContent = text;
|
||||||
listEl.appendChild(empty);
|
listEl.appendChild(empty);
|
||||||
}
|
}
|
||||||
function _addRow(m, onRemove) {
|
function _addRow(m) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'model-switch-item';
|
row.className = 'model-switch-item';
|
||||||
if (m.stale) {
|
if (m.stale) {
|
||||||
@@ -381,20 +376,6 @@ function _initModelPickerDropdown() {
|
|||||||
});
|
});
|
||||||
row.appendChild(favDot);
|
row.appendChild(favDot);
|
||||||
|
|
||||||
// Remove-from-recent button (shown only for Recent section items).
|
|
||||||
if (onRemove) {
|
|
||||||
const rmBtn = document.createElement('button');
|
|
||||||
rmBtn.type = 'button';
|
|
||||||
rmBtn.className = 'mp-remove-dot';
|
|
||||||
rmBtn.textContent = '×';
|
|
||||||
rmBtn.title = 'Remove from recent';
|
|
||||||
rmBtn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove();
|
|
||||||
});
|
|
||||||
row.appendChild(rmBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.addEventListener('click', () => _pick(m));
|
row.addEventListener('click', () => _pick(m));
|
||||||
listEl.appendChild(row);
|
listEl.appendChild(row);
|
||||||
}
|
}
|
||||||
@@ -411,7 +392,8 @@ function _initModelPickerDropdown() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Browse mode: sections in order: Favorites → Recent (big catalogs only) → All / Providers ──
|
// ── Browse mode: Favorites (manual) + Recent (auto), with dedupe. ──
|
||||||
|
// Rules:
|
||||||
// 1. Never list the same model twice in the dropdown. Favorites
|
// 1. Never list the same model twice in the dropdown. Favorites
|
||||||
// win over Recent (if you favorited it, that's where it
|
// win over Recent (if you favorited it, that's where it
|
||||||
// belongs — Recent shouldn't show it again as duplicate).
|
// belongs — Recent shouldn't show it again as duplicate).
|
||||||
@@ -436,13 +418,7 @@ function _initModelPickerDropdown() {
|
|||||||
.slice(0, RECENT_MAX);
|
.slice(0, RECENT_MAX);
|
||||||
if (recentModels.length) {
|
if (recentModels.length) {
|
||||||
_addSection('Recent');
|
_addSection('Recent');
|
||||||
recentModels.forEach(m => {
|
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||||
shown.add(m.mid);
|
|
||||||
_addRow(m, () => {
|
|
||||||
_removeRecent(m.mid);
|
|
||||||
_populate('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-5
@@ -2736,13 +2736,14 @@ async function initEmailAccountsSettings() {
|
|||||||
<h3 style="font-size:12px;margin:0 0 8px">${isEdit ? 'Edit Account' : 'New Account'}</h3>
|
<h3 style="font-size:12px;margin:0 0 8px">${isEdit ? 'Edit Account' : 'New Account'}</h3>
|
||||||
<div class="settings-col">
|
<div class="settings-col">
|
||||||
<div class="settings-row"><label class="settings-label">Provider${_hint('Pick a known provider to auto-fill the IMAP and SMTP host/port. Choose Custom to type your own.')}</label><select id="eaf-provider" class="settings-select"><option value="">Custom…</option>${_providerOptions}</select></div>
|
<div class="settings-row"><label class="settings-label">Provider${_hint('Pick a known provider to auto-fill the IMAP and SMTP host/port. Choose Custom to type your own.')}</label><select id="eaf-provider" class="settings-select"><option value="">Custom…</option>${_providerOptions}</select></div>
|
||||||
|
<div id="eaf-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="eaf-name" class="settings-input" placeholder="(optional — leave blank to use email)" value="${esc(a.name || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="eaf-name" class="settings-input" placeholder="(optional — leave blank to use email)" value="${esc(a.name || '')}"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="eaf-from" class="settings-input" placeholder="you@example.com" value="${esc(a.from_address || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="eaf-from" class="settings-input" placeholder="you@example.com" value="${esc(a.from_address || '')}"></div>
|
||||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:6px 0 2px">IMAP (Receiving)</div>
|
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:6px 0 2px">IMAP (Receiving)</div>
|
||||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="eaf-imap-host" class="settings-input" value="${esc(a.imap_host || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="eaf-imap-host" class="settings-input" value="${esc(a.imap_host || '')}"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="eaf-imap-port" class="settings-input" type="number" value="${esc(a.imap_port || 993)}" style="max-width:100px"></div>
|
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="eaf-imap-port" class="settings-input" type="number" value="${esc(a.imap_port || 993)}" style="max-width:100px"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Username${_hint('Usually your full email address.')}</label><input id="eaf-imap-user" class="settings-input" value="${esc(a.imap_user || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Username${_hint('Usually your full email address.')}</label><input id="eaf-imap-user" class="settings-input" value="${esc(a.imap_user || '')}"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA (Gmail, iCloud, etc.).')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div>
|
<div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA. Outlook / Office 365 generally requires OAuth and will not work with a normal password here.')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div>
|
||||||
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-imap-starttls" ${a.imap_starttls !== false ? 'checked' : ''}><span class="admin-slider"></span></label></div>
|
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-imap-starttls" ${a.imap_starttls !== false ? 'checked' : ''}><span class="admin-slider"></span></label></div>
|
||||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
||||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
|
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
|
||||||
@@ -2750,7 +2751,7 @@ async function initEmailAccountsSettings() {
|
|||||||
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="eaf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
|
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="eaf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
|
||||||
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (this is right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-smtp-same" ${(!isEdit || (a.smtp_user && a.imap_user && a.smtp_user === a.imap_user)) ? 'checked' : ''}><span class="admin-slider"></span></label></div>
|
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (this is right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-smtp-same" ${(!isEdit || (a.smtp_user && a.imap_user && a.smtp_user === a.imap_user)) ? 'checked' : ''}><span class="admin-slider"></span></label></div>
|
||||||
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="eaf-smtp-user" class="settings-input" value="${esc(a.smtp_user || '')}"></div>
|
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="eaf-smtp-user" class="settings-input" value="${esc(a.smtp_user || '')}"></div>
|
||||||
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="eaf-smtp-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_smtp_password ? '(unchanged)' : ''}"></div>
|
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password. Outlook / Office 365 generally requires OAuth and will not work with a normal password here.')}</label><input id="eaf-smtp-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_smtp_password ? '(unchanged)' : ''}"></div>
|
||||||
<div class="settings-row" style="margin-top:10px;align-items:center;">
|
<div class="settings-row" style="margin-top:10px;align-items:center;">
|
||||||
<button class="admin-btn-add" id="eaf-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
|
<button class="admin-btn-add" id="eaf-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
@@ -2765,8 +2766,29 @@ async function initEmailAccountsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const eafProviderNotes = {
|
||||||
|
outlook: {
|
||||||
|
title: 'Outlook / Office 365 needs OAuth',
|
||||||
|
body: 'Microsoft disables normal password login for IMAP/SMTP in most Outlook and Microsoft 365 accounts. Odysseus does not support Microsoft OAuth/Graph mail yet, so this preset is only a placeholder for future support.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const eafNoteEl = el('eaf-provider-note');
|
||||||
|
const _renderEafProviderNote = (key) => {
|
||||||
|
const n = eafProviderNotes[key];
|
||||||
|
if (!eafNoteEl || !n) {
|
||||||
|
if (eafNoteEl) {
|
||||||
|
eafNoteEl.style.display = 'none';
|
||||||
|
eafNoteEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eafNoteEl.style.display = '';
|
||||||
|
eafNoteEl.innerHTML = `<div style="font-weight:600;margin-bottom:3px;">${esc(n.title)}</div><div style="opacity:0.8;">${esc(n.body)}</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
// Provider preset → autofill host/port/STARTTLS for both halves.
|
// Provider preset → autofill host/port/STARTTLS for both halves.
|
||||||
el('eaf-provider').addEventListener('change', (e) => {
|
el('eaf-provider').addEventListener('change', (e) => {
|
||||||
|
_renderEafProviderNote(e.target.value);
|
||||||
const p = PROVIDERS[e.target.value];
|
const p = PROVIDERS[e.target.value];
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
el('eaf-imap-host').value = p.imap.host;
|
el('eaf-imap-host').value = p.imap.host;
|
||||||
@@ -4071,7 +4093,7 @@ async function initUnifiedIntegrations() {
|
|||||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="uf-imap-host" class="settings-input" placeholder="imap.example.com"></div>
|
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="uf-imap-host" class="settings-input" placeholder="imap.example.com"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="uf-imap-port" class="settings-input" type="number" placeholder="993" style="max-width:100px"></div>
|
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="uf-imap-port" class="settings-input" type="number" placeholder="993" style="max-width:100px"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Username${_hint('Yes — your full email address goes here too (e.g. you@gmail.com). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="you@example.com"></div>
|
<div class="settings-row"><label class="settings-label">Username${_hint('Yes — your full email address goes here too (e.g. you@gmail.com). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="you@example.com"></div>
|
||||||
<div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password — those are blocked for IMAP). For Migadu, Fastmail, Outlook, etc.: your regular mailbox password works.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
|
<div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password). For Migadu and Fastmail, your mailbox password usually works. Outlook / Office 365 generally requires OAuth and will not work with this password form.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
|
||||||
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-imap-starttls" checked><span class="admin-slider"></span></label></div>
|
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-imap-starttls" checked><span class="admin-slider"></span></label></div>
|
||||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
||||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
|
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
|
||||||
@@ -4079,7 +4101,7 @@ async function initUnifiedIntegrations() {
|
|||||||
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="uf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
|
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="uf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
|
||||||
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-smtp-same" checked><span class="admin-slider"></span></label></div>
|
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-smtp-same" checked><span class="admin-slider"></span></label></div>
|
||||||
<div class="settings-row uf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="uf-smtp-user" class="settings-input"></div>
|
<div class="settings-row uf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="uf-smtp-user" class="settings-input"></div>
|
||||||
<div class="settings-row uf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="uf-smtp-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
|
<div class="settings-row uf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password. Outlook / Office 365 generally requires OAuth and will not work with this password form.')}</label><input id="uf-smtp-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
|
||||||
<div class="settings-row" style="margin-top:4px"><label class="settings-label">Default${_hint('Use this account whenever no specific account is chosen.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-email-default"><span class="admin-slider"></span></label><span style="font-size:10px;opacity:0.5;margin-left:6px">Used when nothing else is selected</span></div>
|
<div class="settings-row" style="margin-top:4px"><label class="settings-label">Default${_hint('Use this account whenever no specific account is chosen.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-email-default"><span class="admin-slider"></span></label><span style="font-size:10px;opacity:0.5;margin-left:6px">Used when nothing else is selected</span></div>
|
||||||
<div class="settings-row" style="margin-top:10px;align-items:center;">
|
<div class="settings-row" style="margin-top:10px;align-items:center;">
|
||||||
<button class="admin-btn-add" id="uf-email-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
|
<button class="admin-btn-add" id="uf-email-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
|
||||||
@@ -4124,6 +4146,12 @@ async function initUnifiedIntegrations() {
|
|||||||
body: 'Generate an App Password from Yahoo Account Security (requires 2-Step Verification enabled) and paste it as the Password.',
|
body: 'Generate an App Password from Yahoo Account Security (requires 2-Step Verification enabled) and paste it as the Password.',
|
||||||
url: 'https://login.yahoo.com/account/security/app-passwords',
|
url: 'https://login.yahoo.com/account/security/app-passwords',
|
||||||
},
|
},
|
||||||
|
outlook: {
|
||||||
|
title: 'Outlook / Office 365 needs OAuth',
|
||||||
|
body: 'Microsoft disables normal password login for IMAP/SMTP in most Outlook and Microsoft 365 accounts. Odysseus does not support Microsoft OAuth/Graph mail yet, so this preset is only a placeholder for future support.',
|
||||||
|
url: 'https://learn.microsoft.com/exchange/clients-and-mobile-in-exchange-online/disable-basic-authentication-in-exchange-online',
|
||||||
|
linkLabel: 'Read Microsoft note',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const noteEl = el('uf-email-provider-note');
|
const noteEl = el('uf-email-provider-note');
|
||||||
const _copyProviderUrl = async (text) => {
|
const _copyProviderUrl = async (text) => {
|
||||||
@@ -4181,7 +4209,7 @@ async function initUnifiedIntegrations() {
|
|||||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||||
<a href="${esc(n.url)}" target="_blank" rel="noopener noreferrer" class="admin-btn-sm" style="background:var(--red);border-color:var(--red);color:#fff;text-decoration:none;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
|
<a href="${esc(n.url)}" target="_blank" rel="noopener noreferrer" class="admin-btn-sm" style="background:var(--red);border-color:var(--red);color:#fff;text-decoration:none;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
Generate App Password
|
${esc(n.linkLabel || 'Generate App Password')}
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="admin-btn-sm uf-prov-copy" data-url="${esc(n.url)}" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;">
|
<button type="button" class="admin-btn-sm uf-prov-copy" data-url="${esc(n.url)}" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
|||||||
@@ -2940,34 +2940,6 @@ body.bg-pattern-sparkles {
|
|||||||
45% { text-shadow: 0 0 10px color-mix(in srgb, var(--accent, var(--red)) 60%, transparent); }
|
45% { text-shadow: 0 0 10px color-mix(in srgb, var(--accent, var(--red)) 60%, transparent); }
|
||||||
100% { text-shadow: 0 0 0 color-mix(in srgb, var(--accent, var(--red)) 0%, transparent); }
|
100% { text-shadow: 0 0 0 color-mix(in srgb, var(--accent, var(--red)) 0%, transparent); }
|
||||||
}
|
}
|
||||||
/* Inline remove-from-recent button — only shown on Recent rows. */
|
|
||||||
.model-picker-list .mp-remove-dot {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin: -4px -4px -4px 2px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
color: color-mix(in srgb, var(--fg) 28%, transparent);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: color 0.15s ease, opacity 0.15s ease, transform 0.12s ease;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
.model-picker-list .mp-remove-dot:hover {
|
|
||||||
color: var(--red, #ff5555);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
.model-picker-list .mp-remove-dot:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
color: var(--red, #ff5555);
|
|
||||||
}
|
|
||||||
/* First-run hint when a large catalog has no Recent/Favorites yet. */
|
/* First-run hint when a large catalog has no Recent/Favorites yet. */
|
||||||
.model-picker-list .mp-empty-hint {
|
.model-picker-list .mp-empty-hint {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -14154,22 +14126,6 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
|||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
}
|
}
|
||||||
.admin-tools-select {
|
|
||||||
padding: 3px 6px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--fg);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: inherit;
|
|
||||||
height: 26px;
|
|
||||||
min-width: 90px;
|
|
||||||
}
|
|
||||||
.admin-tools-select:hover {
|
|
||||||
background: var(--border);
|
|
||||||
border-color: var(--red);
|
|
||||||
}
|
|
||||||
.admin-spinner {
|
.admin-spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@@ -14285,7 +14241,6 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
|||||||
.admin-ep-actions {
|
.admin-ep-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Uses direct endpoint extraction from setup_api_token_routes().routes and
|
|||||||
fake objects only — no real DB, no network, no external services.
|
fake objects only — no real DB, no network, no external services.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import datetime
|
import datetime
|
||||||
import secrets as _secrets_mod
|
import secrets as _secrets_mod
|
||||||
@@ -292,3 +293,84 @@ def test_delete_missing_token_returns_404_without_invalidating_cache(monkeypatch
|
|||||||
delete_token(request=req, token_id="missing99")
|
delete_token(request=req, token_id="missing99")
|
||||||
assert exc.value.status_code == 404
|
assert exc.value.status_code == 404
|
||||||
invalidator.assert_not_called()
|
invalidator.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. PATCH /api/tokens/{id} — a partial update must not wipe scopes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_request(invalidator, body):
|
||||||
|
"""An admin request whose async .json() yields `body`."""
|
||||||
|
req = _req("alice", is_admin=True, invalidator=invalidator)
|
||||||
|
|
||||||
|
async def _json():
|
||||||
|
return body
|
||||||
|
|
||||||
|
req.json = _json
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_token_rename_preserves_scopes(monkeypatch, token_routes_mod):
|
||||||
|
"""Renaming a token (no 'scopes' key in the body) must keep its scopes.
|
||||||
|
|
||||||
|
Previously update_token recomputed scopes from payload.get("scopes"),
|
||||||
|
which is None on a rename, so _normalize_scopes(None) reset every token to
|
||||||
|
the default ["chat"] scope — a silent privilege/data loss.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
mod = token_routes_mod
|
||||||
|
|
||||||
|
token = SimpleNamespace(
|
||||||
|
id="tok123", name="original", owner="alice",
|
||||||
|
token_prefix="ody_orig", scopes="email:read,email:draft", is_active=True,
|
||||||
|
)
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_session.query.return_value.filter.return_value.first.return_value = token
|
||||||
|
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||||
|
|
||||||
|
invalidator = MagicMock()
|
||||||
|
req = _patch_request(invalidator, {"name": "renamed"})
|
||||||
|
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
|
||||||
|
resp = asyncio.run(update_token(request=req, token_id="tok123"))
|
||||||
|
|
||||||
|
assert token.scopes == "email:read,email:draft" # untouched
|
||||||
|
assert resp["scopes"] == ["email:read", "email:draft"]
|
||||||
|
assert token.name == "renamed"
|
||||||
|
invalidator.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_token_applies_explicit_scopes(monkeypatch, token_routes_mod):
|
||||||
|
"""When the body includes 'scopes', they are normalized and written."""
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
mod = token_routes_mod
|
||||||
|
|
||||||
|
token = SimpleNamespace(
|
||||||
|
id="tok123", name="original", owner="alice",
|
||||||
|
token_prefix="ody_orig", scopes="email:read,email:draft", is_active=True,
|
||||||
|
)
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_session.query.return_value.filter.return_value.first.return_value = token
|
||||||
|
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||||
|
|
||||||
|
req = _patch_request(MagicMock(), {"scopes": ["chat"]})
|
||||||
|
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
|
||||||
|
resp = asyncio.run(update_token(request=req, token_id="tok123"))
|
||||||
|
|
||||||
|
assert token.scopes == "chat"
|
||||||
|
assert resp["scopes"] == ["chat"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_missing_token_returns_404(monkeypatch, token_routes_mod):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
mod = token_routes_mod
|
||||||
|
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_session.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||||
|
|
||||||
|
req = _patch_request(MagicMock(), {"name": "x"})
|
||||||
|
update_token = _get_handler(mod, "PATCH", "/tokens/{token_id}")
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(update_token(request=req, token_id="missing99"))
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""CalDAV sync must not prune the window when it can't fully read the server.
|
||||||
|
|
||||||
|
The prune deletes local caldav rows whose UID the server didn't return. `seen_uids`
|
||||||
|
is built only from objects that parsed, so any parse failure (total or partial)
|
||||||
|
makes it an incomplete view of the server:
|
||||||
|
|
||||||
|
- total failure: `seen_uids` is empty and the prune falls back to `uid.isnot(None)`
|
||||||
|
(match-all), wiping every event in the window;
|
||||||
|
- partial failure: the events that failed to parse are absent from `seen_uids`, so
|
||||||
|
`~uid.in_(seen_uids)` deletes those still-upstream events.
|
||||||
|
|
||||||
|
`_should_prune_window` therefore only allows the prune on a clean read.
|
||||||
|
"""
|
||||||
|
from src.caldav_sync import _should_prune_window
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_runs_on_clean_read():
|
||||||
|
# Clean read with events -> the normal ~uid.in_(seen) prune is safe.
|
||||||
|
assert _should_prune_window({"uid-a", "uid-b"}, parse_failed=False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_runs_when_calendar_genuinely_empty():
|
||||||
|
# Clean read, no objects -> genuinely empty window -> safe to prune.
|
||||||
|
assert _should_prune_window(set(), parse_failed=False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_skipped_when_all_objects_failed_to_parse():
|
||||||
|
# Every object failed -> empty seen_uids is "couldn't read", not "empty
|
||||||
|
# calendar" -> must NOT prune (would delete the whole window).
|
||||||
|
assert _should_prune_window(set(), parse_failed=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_skipped_on_partial_parse_failure():
|
||||||
|
# Some objects parsed and at least one failed: seen_uids is incomplete, so
|
||||||
|
# pruning would delete the unparsed-but-still-upstream events. Skipping the
|
||||||
|
# prune keeps the local copy of the unparsed event instead of deleting it.
|
||||||
|
assert _should_prune_window({"parsed-uid"}, parse_failed=True) is False
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Memory consolidation must delete only memories the model explicitly drops.
|
||||||
|
|
||||||
|
The AI tidy path computed deletions as the complement of the model's `keep`
|
||||||
|
list, so any memory the model simply omitted (a common LLM lapse) was silently
|
||||||
|
deleted. The fix honors the explicit `drop` set, so an omitted memory survives.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import src.builtin_actions as ba
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMM:
|
||||||
|
saved = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def load_all(self):
|
||||||
|
return [
|
||||||
|
{"id": "a", "owner": "alice", "text": "Likes dark roast coffee", "category": "preference"},
|
||||||
|
{"id": "b", "owner": "alice", "text": "Likes dark roast coffee too", "category": "preference"},
|
||||||
|
{"id": "c", "owner": "alice", "text": "Lives in Cairo", "category": "fact"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def save(self, entries):
|
||||||
|
_FakeMM.saved = list(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_omitted_memory_survives_only_explicit_drop(monkeypatch):
|
||||||
|
import src.memory
|
||||||
|
import src.endpoint_resolver
|
||||||
|
import src.llm_core
|
||||||
|
|
||||||
|
_FakeMM.saved = None
|
||||||
|
monkeypatch.setattr(src.memory, "MemoryManager", _FakeMM)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
src.endpoint_resolver, "resolve_endpoint",
|
||||||
|
lambda kind, owner=None: ("http://x/v1", "model", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_llm(**kwargs):
|
||||||
|
# Model keeps 'a', drops 'b', and OMITS 'c' entirely.
|
||||||
|
return json.dumps({
|
||||||
|
"keep": [{"id": "a", "text": "Likes dark roast coffee", "category": "preference"}],
|
||||||
|
"drop": [{"id": "b", "reason": "duplicate of a"}],
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(src.llm_core, "llm_call_async", fake_llm)
|
||||||
|
|
||||||
|
msg, ok = asyncio.run(ba.action_consolidate_memory("alice"))
|
||||||
|
|
||||||
|
assert ok, msg
|
||||||
|
ids = {m["id"] for m in _FakeMM.saved}
|
||||||
|
assert "c" in ids, "omitted memory must NOT be deleted"
|
||||||
|
assert "a" in ids
|
||||||
|
assert "b" not in ids, "explicitly dropped memory should be removed"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Forking a session must not mutate the source session's messages.
|
||||||
|
|
||||||
|
ChatMessage.metadata is a dict. add_message() -> _persist_message() stamps
|
||||||
|
_db_id (and timestamp) onto that dict in place. The fork handler used to pass
|
||||||
|
the source message's metadata dict by reference into the new session, so
|
||||||
|
persisting the fork rewrote the SOURCE messages' _db_id — breaking
|
||||||
|
edit/delete-by-id on the original conversation. The fork must copy the dict.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from core.models import ChatMessage
|
||||||
|
import routes.history_routes as mod
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self, name="", owner=None):
|
||||||
|
self.name = name
|
||||||
|
self.owner = owner
|
||||||
|
self.endpoint_url = ""
|
||||||
|
self.model = ""
|
||||||
|
self.history = []
|
||||||
|
|
||||||
|
def add_message(self, message):
|
||||||
|
# Mirror _persist_message: stamp the in-memory message's metadata.
|
||||||
|
if message.metadata is None:
|
||||||
|
message.metadata = {}
|
||||||
|
message.metadata["_db_id"] = f"new-{len(self.history)}"
|
||||||
|
self.history.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSessionManager:
|
||||||
|
def __init__(self, source):
|
||||||
|
self.sessions = {"src-id": source}
|
||||||
|
self.created = None
|
||||||
|
|
||||||
|
def create_session(self, session_id=None, name=None, endpoint_url=None,
|
||||||
|
model=None, rag=False, owner=None):
|
||||||
|
self.created = _FakeSession(name=name, owner=owner)
|
||||||
|
return self.created
|
||||||
|
|
||||||
|
def save_sessions(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fork_handler(router):
|
||||||
|
for route in router.routes:
|
||||||
|
if "/fork" in getattr(route, "path", "") and "POST" in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("fork route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
|
||||||
|
monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None)
|
||||||
|
|
||||||
|
source = _FakeSession(name="Original", owner="alice")
|
||||||
|
source.history = [
|
||||||
|
ChatMessage("user", "hi", {"_db_id": "src-0"}),
|
||||||
|
ChatMessage("assistant", "yo", {"_db_id": "src-1"}),
|
||||||
|
]
|
||||||
|
sm = _FakeSessionManager(source)
|
||||||
|
|
||||||
|
req = SimpleNamespace()
|
||||||
|
|
||||||
|
async def _json():
|
||||||
|
return {"keep_count": 2}
|
||||||
|
|
||||||
|
req.json = _json
|
||||||
|
|
||||||
|
router = mod.setup_history_routes(sm)
|
||||||
|
fork = _fork_handler(router)
|
||||||
|
result = asyncio.run(fork(request=req, session_id="src-id"))
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert result["kept"] == 2
|
||||||
|
|
||||||
|
# The forked session got its own metadata dicts...
|
||||||
|
new_session = sm.created
|
||||||
|
assert new_session.history[0].metadata is not source.history[0].metadata
|
||||||
|
assert new_session.history[1].metadata is not source.history[1].metadata
|
||||||
|
|
||||||
|
# ...and the source session's _db_id values are untouched.
|
||||||
|
assert source.history[0].metadata["_db_id"] == "src-0"
|
||||||
|
assert source.history[1].metadata["_db_id"] == "src-1"
|
||||||
@@ -9,6 +9,7 @@ Functions covered:
|
|||||||
_download_attachment
|
_download_attachment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import imaplib
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -228,3 +229,176 @@ def test_mcp_download_attachment_logs_out_on_select_failure(monkeypatch):
|
|||||||
f"conn.logout() must be called after select raises in _download_attachment. "
|
f"conn.logout() must be called after select raises in _download_attachment. "
|
||||||
f"Got logout_calls={captured.get('logout_calls')}"
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── connect-time leak: _imap_connect / _open_imap_connection (#3174) ──────────
|
||||||
|
# The cases above all monkeypatch _imap_connect to *succeed*; these cover the
|
||||||
|
# gap where the connect itself fails (bad/expired app password, rejected
|
||||||
|
# STARTTLS) and the already-open socket would otherwise be orphaned.
|
||||||
|
|
||||||
|
|
||||||
|
def test_imap_connect_shuts_down_socket_on_login_failure(monkeypatch):
|
||||||
|
"""A failed login() must close the already-connected socket, not leak it."""
|
||||||
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.shutdown = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"shutdown_calls", captured.get("shutdown_calls", 0) + 1
|
||||||
|
))
|
||||||
|
conn.login = MagicMock(side_effect=imaplib.IMAP4.error(b"AUTHENTICATE failed."))
|
||||||
|
|
||||||
|
monkeypatch.setattr(helpers, "_get_email_config", lambda *a, **kw: {
|
||||||
|
"imap_host": "imap.example.com",
|
||||||
|
"imap_port": 993,
|
||||||
|
"imap_starttls": False,
|
||||||
|
"imap_user": "user@example.com",
|
||||||
|
"imap_password": "wrong",
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(helpers, "_open_imap_connection", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
raised = False
|
||||||
|
try:
|
||||||
|
helpers._imap_connect()
|
||||||
|
except Exception:
|
||||||
|
raised = True
|
||||||
|
|
||||||
|
assert raised, "login failure must propagate to the caller"
|
||||||
|
assert captured.get("shutdown_calls", 0) == 1, (
|
||||||
|
f"conn.shutdown() must be called exactly once when login fails. "
|
||||||
|
f"Got shutdown_calls={captured.get('shutdown_calls')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_imap_connection_shuts_down_on_starttls_failure(monkeypatch):
|
||||||
|
"""A rejected STARTTLS upgrade must close the open plain socket."""
|
||||||
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.shutdown = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"shutdown_calls", captured.get("shutdown_calls", 0) + 1
|
||||||
|
))
|
||||||
|
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
|
||||||
|
|
||||||
|
monkeypatch.setattr(helpers.imaplib, "IMAP4", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
raised = False
|
||||||
|
try:
|
||||||
|
helpers._open_imap_connection("imap.example.com", 143, starttls=True)
|
||||||
|
except Exception:
|
||||||
|
raised = True
|
||||||
|
|
||||||
|
assert raised, "starttls failure must propagate to the caller"
|
||||||
|
assert captured.get("shutdown_calls", 0) == 1, (
|
||||||
|
f"conn.shutdown() must be called exactly once when STARTTLS fails. "
|
||||||
|
f"Got shutdown_calls={captured.get('shutdown_calls')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── connect-time leak: mcp_servers/email_server.py (folded in per review #3363) ──
|
||||||
|
# Same connect-then-step pattern as the routes path. IMAP closes pre-auth with
|
||||||
|
# shutdown(); SMTP has no shutdown(), so close() (socket close, no QUIT).
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg_imap(ssl=True, starttls=False):
|
||||||
|
return {
|
||||||
|
"imap_ssl": ssl, "imap_starttls": starttls,
|
||||||
|
"imap_host": "imap.example.com", "imap_port": 993,
|
||||||
|
"imap_user": "user@example.com", "imap_password": "wrong",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_imap_connect_shuts_down_on_login_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.shutdown = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"shutdown_calls", captured.get("shutdown_calls", 0) + 1))
|
||||||
|
conn.login = MagicMock(side_effect=imaplib.IMAP4.error(b"AUTHENTICATE failed."))
|
||||||
|
monkeypatch.setattr(srv, "_load_config", lambda *a, **kw: _cfg_imap(ssl=True))
|
||||||
|
monkeypatch.setattr(srv.imaplib, "IMAP4_SSL", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
raised = False
|
||||||
|
try:
|
||||||
|
srv._imap_connect()
|
||||||
|
except Exception:
|
||||||
|
raised = True
|
||||||
|
assert raised, "login failure must propagate"
|
||||||
|
assert captured.get("shutdown_calls", 0) == 1, (
|
||||||
|
f"shutdown() must be called once on MCP IMAP login failure. Got {captured.get('shutdown_calls')}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_imap_connect_shuts_down_on_starttls_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.shutdown = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"shutdown_calls", captured.get("shutdown_calls", 0) + 1))
|
||||||
|
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
|
||||||
|
monkeypatch.setattr(srv, "_load_config", lambda *a, **kw: _cfg_imap(ssl=False, starttls=True))
|
||||||
|
monkeypatch.setattr(srv.imaplib, "IMAP4", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
raised = False
|
||||||
|
try:
|
||||||
|
srv._imap_connect()
|
||||||
|
except Exception:
|
||||||
|
raised = True
|
||||||
|
assert raised, "starttls failure must propagate"
|
||||||
|
assert captured.get("shutdown_calls", 0) == 1, (
|
||||||
|
f"shutdown() must be called once on MCP IMAP STARTTLS failure. Got {captured.get('shutdown_calls')}")
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg_smtp(security):
|
||||||
|
return {
|
||||||
|
"smtp_host": "smtp.example.com",
|
||||||
|
"smtp_port": 587 if security == "starttls" else 465,
|
||||||
|
"smtp_security": security, "smtp_user": "user@example.com",
|
||||||
|
"smtp_password": "wrong", "account_name": "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_smtp_connect_closes_on_login_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.close = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"close_calls", captured.get("close_calls", 0) + 1))
|
||||||
|
conn.login = MagicMock(side_effect=Exception("SMTP auth failed"))
|
||||||
|
monkeypatch.setattr(srv, "_load_config", lambda *a, **kw: _cfg_smtp("ssl"))
|
||||||
|
monkeypatch.setattr(srv, "_smtp_ready", lambda cfg: True)
|
||||||
|
monkeypatch.setattr(srv.smtplib, "SMTP_SSL", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
raised = False
|
||||||
|
try:
|
||||||
|
srv._smtp_connect()
|
||||||
|
except Exception:
|
||||||
|
raised = True
|
||||||
|
assert raised, "login failure must propagate"
|
||||||
|
assert captured.get("close_calls", 0) == 1, (
|
||||||
|
f"close() must be called once on MCP SMTP login failure. Got {captured.get('close_calls')}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_smtp_connect_closes_on_starttls_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.close = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"close_calls", captured.get("close_calls", 0) + 1))
|
||||||
|
conn.starttls = MagicMock(side_effect=Exception("STARTTLS rejected"))
|
||||||
|
monkeypatch.setattr(srv, "_load_config", lambda *a, **kw: _cfg_smtp("starttls"))
|
||||||
|
monkeypatch.setattr(srv, "_smtp_ready", lambda cfg: True)
|
||||||
|
monkeypatch.setattr(srv.smtplib, "SMTP", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
raised = False
|
||||||
|
try:
|
||||||
|
srv._smtp_connect()
|
||||||
|
except Exception:
|
||||||
|
raised = True
|
||||||
|
assert raised, "starttls failure must propagate"
|
||||||
|
assert captured.get("close_calls", 0) == 1, (
|
||||||
|
f"close() must be called once on MCP SMTP STARTTLS failure. Got {captured.get('close_calls')}")
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""_parse_extraction_json must survive reasoning-model noise.
|
||||||
|
|
||||||
|
The extraction model wraps its JSON array in <think> blocks, ```json fences,
|
||||||
|
or leading/trailing prose. The helper strips that noise and slices the array
|
||||||
|
unconditionally — a reply that starts with '[' can still carry trailing
|
||||||
|
commentary like "[...] Done!" that would otherwise break json.loads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from services.memory.memory_extractor import _parse_extraction_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_think_prefixed_array_parses_to_one_fact():
|
||||||
|
raw = '<think>reasoning...</think>\n[{"text": "x", "category": "fact"}]'
|
||||||
|
assert _parse_extraction_json(raw) == [{"text": "x", "category": "fact"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenced_json_block_parses():
|
||||||
|
raw = '```json\n[{"text": "x", "category": "fact"}]\n```'
|
||||||
|
assert _parse_extraction_json(raw) == [{"text": "x", "category": "fact"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_leading_prose_before_array_parses():
|
||||||
|
raw = 'Here are the durable facts:\n[{"text": "x", "category": "fact"}]'
|
||||||
|
assert _parse_extraction_json(raw) == [{"text": "x", "category": "fact"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_commentary_after_array_parses():
|
||||||
|
# Exercises the unconditional slice: text starts with '[' but has trailing
|
||||||
|
# commentary that the old `text[0] != "["` guard skipped, breaking json.loads.
|
||||||
|
raw = '[{"text": "x", "category": "fact"}] Done!'
|
||||||
|
assert _parse_extraction_json(raw) == [{"text": "x", "category": "fact"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_no_array_returns_empty():
|
||||||
|
assert _parse_extraction_json("no array here, sorry") == []
|
||||||
|
assert _parse_extraction_json("") == []
|
||||||
@@ -10,6 +10,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules, preserve_import_state
|
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules, preserve_import_state
|
||||||
|
|
||||||
@@ -1273,6 +1274,24 @@ def test_background_refresh_failure_keeps_existing_cached_models(monkeypatch):
|
|||||||
assert json.loads(ep.cached_models) == ["cached-model"]
|
assert json.loads(ep.cached_models) == ["cached-model"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_models_auth_gate_fails_closed_on_unexpected_error(monkeypatch):
|
||||||
|
"""A non-HTTPException raised while checking auth must yield 500, not a
|
||||||
|
silent pass-through that leaks the model list to an unauthenticated caller."""
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
state=SimpleNamespace(current_user=None),
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_configured=True))),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_route_endpoint(router, "/api/models")(request)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
def test_llm_core_list_model_ids_uses_cached_configured_proxy(monkeypatch):
|
def test_llm_core_list_model_ids_uses_cached_configured_proxy(monkeypatch):
|
||||||
ep = _route_ep(
|
ep = _route_ep(
|
||||||
"proxy",
|
"proxy",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Regression: PresetManager.save() must persist presets atomically.
|
||||||
|
|
||||||
|
save() used a plain open("w") + json.dump, which truncates presets.json before
|
||||||
|
writing the new content. A crash / power loss / serialization error mid-write
|
||||||
|
leaves the file truncated or empty — the user loses every saved preset. The
|
||||||
|
save now goes through core.atomic_io.atomic_write_json (tmp file + os.replace),
|
||||||
|
which the rest of the codebase already uses for JSON state files.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
|
||||||
|
from src.preset_manager import PresetManager
|
||||||
|
|
||||||
|
|
||||||
|
class _Unserializable:
|
||||||
|
"""json.dump cannot serialize this — stands in for a mid-write failure."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_uses_atomic_write_json():
|
||||||
|
src = inspect.getsource(PresetManager.save)
|
||||||
|
assert "atomic_write_json" in src, "save() must persist via atomic_write_json"
|
||||||
|
assert "open(" not in src, "save() must not write presets.json with a plain open('w')"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_save_does_not_truncate_existing_file(tmp_path):
|
||||||
|
mgr = PresetManager(str(tmp_path))
|
||||||
|
assert mgr.save({"custom": {"name": "keep"}}) is True
|
||||||
|
before = (tmp_path / "presets.json").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# A payload that cannot be serialized must not clobber the good file.
|
||||||
|
assert mgr.save({"custom": {"obj": _Unserializable()}}) is False
|
||||||
|
|
||||||
|
after = (tmp_path / "presets.json").read_text(encoding="utf-8")
|
||||||
|
assert after == before
|
||||||
|
assert json.loads(after) == {"custom": {"name": "keep"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_round_trip(tmp_path):
|
||||||
|
mgr = PresetManager(str(tmp_path))
|
||||||
|
assert mgr.save({"custom": {"name": "X", "temperature": 0.5}}) is True
|
||||||
|
|
||||||
|
reloaded = PresetManager(str(tmp_path))
|
||||||
|
assert reloaded.presets["custom"]["name"] == "X"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Route-level owner-scope test for POST /api/presets/expand.
|
||||||
|
|
||||||
|
`expand_character_prompt` resolves a model endpoint to run its LLM call. It must
|
||||||
|
scope that lookup to the calling user, otherwise it can resolve another owner's
|
||||||
|
ModelEndpoint (and its decrypted api_key) in a multi-user deployment. See #2283.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from routes.preset_routes import setup_preset_routes
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRequest:
|
||||||
|
"""Minimal stand-in: an async ``json()`` body plus a ``state`` namespace."""
|
||||||
|
|
||||||
|
def __init__(self, body, **state):
|
||||||
|
self._body = body
|
||||||
|
self.state = SimpleNamespace(**state)
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_endpoint():
|
||||||
|
router = setup_preset_routes(MagicMock())
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", "") == "/api/presets/expand" and "POST" in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("POST /api/presets/expand route not registered")
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_model_pipeline(monkeypatch):
|
||||||
|
"""Capture the owner passed to _resolve_model and stub the LLM call."""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_resolve_model(spec, owner=None):
|
||||||
|
seen["spec"] = spec
|
||||||
|
seen["owner"] = owner
|
||||||
|
return ("http://endpoint.local/v1", "test-model", {})
|
||||||
|
|
||||||
|
async def fake_llm_call_async(url, model, messages, **kwargs):
|
||||||
|
return " expanded prompt "
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.ai_interaction._resolve_model", fake_resolve_model)
|
||||||
|
monkeypatch.setattr("src.llm_core.llm_call_async", fake_llm_call_async)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_scopes_model_resolution_to_cookie_user(monkeypatch):
|
||||||
|
seen = _patch_model_pipeline(monkeypatch)
|
||||||
|
endpoint = _expand_endpoint()
|
||||||
|
|
||||||
|
req = _FakeRequest({"name": "Pirate", "prompt": "talks like a pirate", "model": "test-model"},
|
||||||
|
current_user="alice")
|
||||||
|
result = asyncio.run(endpoint(req))
|
||||||
|
|
||||||
|
assert seen["owner"] == "alice"
|
||||||
|
assert seen["spec"] == "test-model"
|
||||||
|
assert result == {"success": True, "prompt": "expanded prompt"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_attributes_bearer_token_to_its_owner(monkeypatch):
|
||||||
|
# effective_user (not get_current_user) resolves a bearer ody_ caller to the
|
||||||
|
# token's real owner instead of the sandbox "api" pseudo-user.
|
||||||
|
seen = _patch_model_pipeline(monkeypatch)
|
||||||
|
endpoint = _expand_endpoint()
|
||||||
|
|
||||||
|
req = _FakeRequest({"name": "Pirate", "model": ""},
|
||||||
|
current_user="api", api_token=True, api_token_owner="bob")
|
||||||
|
asyncio.run(endpoint(req))
|
||||||
|
|
||||||
|
assert seen["owner"] == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_short_circuits_without_input(monkeypatch):
|
||||||
|
seen = _patch_model_pipeline(monkeypatch)
|
||||||
|
endpoint = _expand_endpoint()
|
||||||
|
|
||||||
|
req = _FakeRequest({}, current_user="alice")
|
||||||
|
result = asyncio.run(endpoint(req))
|
||||||
|
|
||||||
|
# Nothing to expand: no model resolution attempted.
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "owner" not in seen
|
||||||
@@ -233,6 +233,43 @@ def test_q_empty_input():
|
|||||||
assert _q(None) == '""'
|
assert _q(None) == '""'
|
||||||
|
|
||||||
|
|
||||||
|
# ── provider auth error normalization ──────────────────────────
|
||||||
|
|
||||||
|
def _import_friendly_email_auth_error():
|
||||||
|
sys.modules.pop("routes.email_helpers", None)
|
||||||
|
from routes.email_helpers import _friendly_email_auth_error # noqa: WPS433
|
||||||
|
return _friendly_email_auth_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_outlook_smtp_basic_auth_error_is_actionable():
|
||||||
|
normalize = _import_friendly_email_auth_error()
|
||||||
|
msg = normalize(
|
||||||
|
"SMTP",
|
||||||
|
"smtp.office365.com",
|
||||||
|
"(535, b'5.7.139 Authentication unsuccessful, basic authentication is disabled.')",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Microsoft no longer accepts normal mailbox passwords" in msg
|
||||||
|
assert "OAuth/Graph" in msg
|
||||||
|
assert "535" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_outlook_imap_authenticate_failed_is_actionable():
|
||||||
|
normalize = _import_friendly_email_auth_error()
|
||||||
|
msg = normalize("IMAP", "outlook.office365.com", "b'AUTHENTICATE failed.'")
|
||||||
|
|
||||||
|
assert "Microsoft no longer accepts normal mailbox passwords" in msg
|
||||||
|
assert "Outlook/Office 365" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_auth_error_still_passes_through_truncated():
|
||||||
|
normalize = _import_friendly_email_auth_error()
|
||||||
|
msg = normalize("IMAP", "imap.example.com", "bad credentials " + ("x" * 300))
|
||||||
|
|
||||||
|
assert msg.startswith("bad credentials")
|
||||||
|
assert len(msg) == 200
|
||||||
|
|
||||||
|
|
||||||
# ── compose-upload path traversal block ─────────────────────────
|
# ── compose-upload path traversal block ─────────────────────────
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Owner-scope tests for the remaining _resolve_model call sites.
|
||||||
|
|
||||||
|
Both the teacher-escalation path and the skill-audit teacher resolution map a
|
||||||
|
model spec to an endpoint (and its decrypted api_key). Like /presets/expand,
|
||||||
|
that lookup must be scoped to the calling user, otherwise it can resolve another
|
||||||
|
owner's ModelEndpoint in a multi-user deployment. See #2283.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import src.teacher_escalation as teacher_escalation
|
||||||
|
import routes.skills_routes as skills_routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_teacher_scopes_model_resolution_to_owner(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_resolve_model(spec, owner=None):
|
||||||
|
seen["spec"] = spec
|
||||||
|
seen["owner"] = owner
|
||||||
|
return ("http://endpoint.local/v1", "teacher-model", {})
|
||||||
|
|
||||||
|
async def fake_llm_call_async(url, model, messages, **kwargs):
|
||||||
|
return "teacher reply"
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.ai_interaction._resolve_model", fake_resolve_model)
|
||||||
|
monkeypatch.setattr("src.ai_interaction._TEACHER_SYSTEM_PROMPT", "sys", raising=False)
|
||||||
|
monkeypatch.setattr("src.llm_core.llm_call_async", fake_llm_call_async)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
teacher_escalation._call_teacher("teacher-model", "prompt", owner="alice")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "teacher reply"
|
||||||
|
assert seen["owner"] == "alice"
|
||||||
|
assert seen["spec"] == "teacher-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_teacher_resolution_scoped_to_owner(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_resolve_endpoint(role, owner=None):
|
||||||
|
return ("http://worker.local/v1", "worker-model", {})
|
||||||
|
|
||||||
|
def fake_get_setting(key, default=None):
|
||||||
|
return {"teacher_enabled": True, "teacher_model": "teacher-model"}.get(key, default)
|
||||||
|
|
||||||
|
def fake_resolve_model(spec, owner=None):
|
||||||
|
seen["spec"] = spec
|
||||||
|
seen["owner"] = owner
|
||||||
|
return ("http://endpoint.local/v1", "teacher-model", {})
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.endpoint_resolver.resolve_endpoint", fake_resolve_endpoint)
|
||||||
|
monkeypatch.setattr("src.settings.get_setting", fake_get_setting)
|
||||||
|
monkeypatch.setattr("src.ai_interaction._resolve_model", fake_resolve_model)
|
||||||
|
# list_model_ids is best-effort; force it to no-op so the worker model passes through.
|
||||||
|
monkeypatch.setattr("src.llm_core.list_model_ids", lambda url, headers=None: [])
|
||||||
|
|
||||||
|
url, model, headers, teacher = skills_routes._resolve_audit_models(owner="alice")
|
||||||
|
|
||||||
|
assert (url, model) == ("http://worker.local/v1", "worker-model")
|
||||||
|
assert teacher == ("http://endpoint.local/v1", "teacher-model", {})
|
||||||
|
assert seen["owner"] == "alice"
|
||||||
|
assert seen["spec"] == "teacher-model"
|
||||||
Reference in New Issue
Block a user