mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-04 12:12:21 +01:00
Compare commits
12
Commits
7c7ac1021a
...
5f58f9a45f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f58f9a45f | ||
|
|
aaef6b1c49 | ||
|
|
193dc2f085 | ||
|
|
48f5182286 | ||
|
|
5869106089 | ||
|
|
089246614d | ||
|
|
271489a10c | ||
|
|
729a30a10e | ||
|
|
b6607d219d | ||
|
|
c58cb067f2 | ||
|
|
6e80d0de08 | ||
|
|
5939aec69f |
@@ -82,8 +82,7 @@ python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
|
||||
downloads and serves. The app itself is lightweight; local model serving is the
|
||||
heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
|
||||
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when
|
||||
you intentionally want LAN/reverse-proxy access.
|
||||
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||
|
||||
### Apple Silicon
|
||||
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
|
||||
|
||||
@@ -537,7 +537,8 @@ app.include_router(setup_admin_wipe_routes(session_manager))
|
||||
|
||||
# Memory
|
||||
from routes.memory_routes import setup_memory_routes
|
||||
app.include_router(setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector))
|
||||
memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)
|
||||
app.include_router(memory_router)
|
||||
from routes.skills_routes import setup_skills_routes
|
||||
app.include_router(setup_skills_routes(skills_manager))
|
||||
|
||||
@@ -600,7 +601,8 @@ logger.info("STT service initialized (provider managed via settings)")
|
||||
|
||||
# Documents (artifacts/canvas)
|
||||
from routes.document_routes import setup_document_routes
|
||||
app.include_router(setup_document_routes(session_manager, upload_handler))
|
||||
document_router = setup_document_routes(session_manager, upload_handler)
|
||||
app.include_router(document_router)
|
||||
|
||||
# Signatures (reusable image stamps)
|
||||
from routes.signature_routes import setup_signature_routes
|
||||
@@ -627,7 +629,8 @@ app.include_router(setup_assistant_routes(task_scheduler))
|
||||
|
||||
# Calendar (CalDAV)
|
||||
from routes.calendar_routes import setup_calendar_routes
|
||||
app.include_router(setup_calendar_routes())
|
||||
calendar_router = setup_calendar_routes()
|
||||
app.include_router(calendar_router)
|
||||
|
||||
# Shell (user-facing command execution)
|
||||
from routes.shell_routes import setup_shell_routes
|
||||
@@ -690,7 +693,22 @@ app.include_router(setup_note_routes(task_scheduler))
|
||||
|
||||
# Email
|
||||
from routes.email_routes import setup_email_routes
|
||||
app.include_router(setup_email_routes())
|
||||
email_router = setup_email_routes()
|
||||
app.include_router(email_router)
|
||||
|
||||
# Codex integration — HTTP surface for the Codex plugin/MCP bridge. Reuses
|
||||
# api_token scopes (todos:read|write, email:read|draft|send) so external
|
||||
# Codex sessions can only touch the data the user explicitly allowed. Mounted
|
||||
# AFTER email so the codex_routes can borrow the email router for shared
|
||||
# search/threading helpers.
|
||||
from routes.codex_routes import setup_codex_routes, setup_claude_routes
|
||||
app.include_router(setup_codex_routes(
|
||||
email_router=email_router,
|
||||
memory_router=memory_router,
|
||||
calendar_router=calendar_router,
|
||||
document_router=document_router,
|
||||
))
|
||||
app.include_router(setup_claude_routes())
|
||||
|
||||
from routes.vault_routes import setup_vault_routes
|
||||
app.include_router(setup_vault_routes())
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Odysseus Claude Code Integration
|
||||
|
||||
This directory contains the Claude Code skill bundle for Odysseus.
|
||||
|
||||
## User Flow
|
||||
|
||||
1. Open Odysseus Settings > Integrations.
|
||||
2. Add a Claude Agent.
|
||||
3. Copy the full setup commands shown after the generated token.
|
||||
4. Toggle the tools Claude is allowed to use.
|
||||
5. Configure the terminal Claude Code session:
|
||||
|
||||
```bash
|
||||
export ODYSSEUS_URL=http://your-odysseus-host:7000
|
||||
export ODYSSEUS_API_TOKEN=ody_generated_token
|
||||
mkdir -p ~/.claude
|
||||
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip
|
||||
python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/
|
||||
```
|
||||
|
||||
Claude Code auto-loads anything under `~/.claude/skills/`, so the `odysseus` skill is
|
||||
available in any session that has `ODYSSEUS_URL` and `ODYSSEUS_API_TOKEN` in its
|
||||
environment.
|
||||
|
||||
## What's in the bundle
|
||||
|
||||
- `skills/odysseus/SKILL.md` — the skill definition Claude Code reads.
|
||||
- `skills/odysseus/scripts/odysseus_api.py` — small helper that calls the scoped
|
||||
`/api/codex/*` endpoints (these are the canonical scope-gated agent API; the
|
||||
`codex` path is historic and shared by all agent integrations).
|
||||
|
||||
## Scope enforcement
|
||||
|
||||
The token is scope-gated. Every tool surface is checked server-side in Odysseus,
|
||||
so even if Claude tries to call a forbidden endpoint, it gets `403` until the
|
||||
user enables the matching toggle in Settings > Integrations > Claude Agent.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: odysseus
|
||||
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||
---
|
||||
|
||||
# Odysseus
|
||||
|
||||
Use this skill when a user asks to interact with Odysseus from Claude Code.
|
||||
|
||||
## Configuration
|
||||
|
||||
Expect these environment variables:
|
||||
|
||||
- `ODYSSEUS_URL`: Base URL for the user's Odysseus instance, for example `http://127.0.0.1:7000`.
|
||||
- `ODYSSEUS_API_TOKEN`: Scoped API token created in Odysseus Settings > Integrations > Add Integration > Claude Agent.
|
||||
|
||||
If either value is missing, do not guess credentials. Tell the user to create a Claude Agent token in Odysseus Settings and expose both values to the terminal session.
|
||||
|
||||
## When to use what
|
||||
|
||||
- **Reminder ("remind me at 5pm to do X")** → TODO with `due_date`. The due_date IS the reminder — it fires a notification automatically via the user's configured channel (browser/email/ntfy). **Do NOT create a calendar event for a reminder.** Creating a calendar event named "Reminder" does NOT trigger a notification — it's just a time block on the calendar.
|
||||
- **Calendar event ("meeting at 3pm", "dentist Tuesday 10am")** → calendar event. Use for scheduled time blocks, meetings, appointments, recurring schedules. These show up on the calendar grid; reminders for them are configured separately in Odysseus settings.
|
||||
- **Note / freeform info ("note that the wifi password is ...")** → memory or todo without a due_date (depending on whether it's a fact about the user or an action item).
|
||||
- **Persistent fact / preference about the user** → memory.
|
||||
|
||||
If the user says "reminder" + a time, default to TODO with due_date. Only switch to calendar if the user explicitly says "calendar", "event", "meeting", "appointment", or describes a time *range*.
|
||||
|
||||
## Safety
|
||||
|
||||
- All Odysseus data access MUST go through the scoped HTTP API under `/api/codex/*` (the canonical scope-gated agent API, shared by all agent integrations).
|
||||
- Check `/api/codex/capabilities` before using a tool surface.
|
||||
- Treat `403` as an intentional Settings restriction. Do not work around it.
|
||||
- Do not use SSH, Docker, direct Python imports, SQLite queries, MCP internals, browser cookies, or local files to read/write Odysseus user data.
|
||||
- Do not call helpers like `do_manage_notes`, email MCP internals, or database sessions directly for user data, even if shell access exists.
|
||||
- Never send email directly unless the user explicitly asks to send and the token has a send-capable scope.
|
||||
- Keep actions scoped to the token owner.
|
||||
|
||||
## Todos
|
||||
|
||||
The scoped agent API supports todos/checklists:
|
||||
|
||||
- `GET /api/codex/todos`
|
||||
- `POST /api/codex/todos`
|
||||
|
||||
Use the bundled helper script when available:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py todos list
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py todos add "Follow up"
|
||||
```
|
||||
|
||||
Supported todo actions are `list`, `add`, `update`, `delete`, and `toggle_item`.
|
||||
|
||||
**Reminders (todos with a due date)** — the backend parses natural language. Send `due_date` in the body via the generic POST so the time becomes a structured reminder, NOT a literal substring inside the title. The `todos add TITLE` shortcut only sets the title, so use the POST form for anything with a time:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/todos '{"action":"add","title":"Call dentist","due_date":"tomorrow at 5pm"}'
|
||||
```
|
||||
|
||||
The backend accepts both ISO timestamps and natural language like `"tomorrow 5pm"`, `"next Monday 9am"`, `"in 2 hours"`. It anchors to the user's timezone.
|
||||
|
||||
## Email
|
||||
|
||||
The scoped agent API supports email reads:
|
||||
|
||||
- `GET /api/codex/emails?folder=INBOX&limit=10&offset=0&filter=all`
|
||||
- `GET /api/codex/emails/{uid}?folder=INBOX`
|
||||
|
||||
Use the bundled helper script when available:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py emails list 5
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py emails read UID
|
||||
```
|
||||
|
||||
If `/api/codex/capabilities` does not show `email.read: true`, do not inspect email. Ask the user to enable Email read in the Claude Agent settings.
|
||||
|
||||
## Memory
|
||||
|
||||
- `GET /api/codex/memory` — list memories for the token owner.
|
||||
- `POST /api/codex/memory` — body `{"text": "...", "category": "fact", "source": "user", "session_id": null}`. Requires `memory:write`.
|
||||
- `DELETE /api/codex/memory/{memory_id}` — remove a memory entry. Requires `memory:write`.
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py GET /api/codex/memory
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory '{"text":"User prefers SI units","category":"preference"}'
|
||||
```
|
||||
|
||||
## Calendar
|
||||
|
||||
- `GET /api/codex/calendar/events?start=ISO&end=ISO` — list events in window.
|
||||
- `POST /api/codex/calendar/events` — body matches `EventCreate` (`summary`, `dtstart`, `dtend`, `all_day`, `description`, `location`, `calendar_href`, `rrule`, `color`). Requires `calendar:write`.
|
||||
- `DELETE /api/codex/calendar/events/{uid}` — delete event by uid (the value returned in the POST response). Requires `calendar:write`.
|
||||
|
||||
## Documents
|
||||
|
||||
- `GET /api/codex/documents?search=...&limit=50` — paginated library.
|
||||
- `GET /api/codex/documents/{doc_id}` — fetch one document.
|
||||
- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`.
|
||||
- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`.
|
||||
|
||||
## Email draft + send
|
||||
|
||||
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
|
||||
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
|
||||
|
||||
## Forbidden Bypass Pattern
|
||||
|
||||
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Claude Agent tool toggle instead.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small Odysseus scoped API helper for Codex terminal sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def _usage() -> int:
|
||||
print("usage:", file=sys.stderr)
|
||||
print(" odysseus_api.py capabilities", file=sys.stderr)
|
||||
print(" odysseus_api.py todos list", file=sys.stderr)
|
||||
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
||||
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
||||
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
||||
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def _config() -> tuple[str, str] | None:
|
||||
base_url = os.environ.get("ODYSSEUS_URL", "").strip().rstrip("/")
|
||||
token = os.environ.get("ODYSSEUS_API_TOKEN", "").strip()
|
||||
missing = []
|
||||
if not base_url:
|
||||
missing.append("ODYSSEUS_URL")
|
||||
if not token:
|
||||
missing.append("ODYSSEUS_API_TOKEN")
|
||||
if missing:
|
||||
print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr)
|
||||
return None
|
||||
return base_url, token
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
return _usage()
|
||||
|
||||
command = sys.argv[1].lower()
|
||||
if command == "capabilities":
|
||||
method = "GET"
|
||||
path = "/api/codex/capabilities"
|
||||
body = None
|
||||
elif command == "todos":
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
action = sys.argv[2].lower()
|
||||
path = "/api/codex/todos"
|
||||
if action == "list":
|
||||
method = "GET"
|
||||
body = None
|
||||
elif action == "add" and len(sys.argv) >= 4:
|
||||
method = "POST"
|
||||
body = json.dumps({"action": "add", "title": " ".join(sys.argv[3:])})
|
||||
else:
|
||||
return _usage()
|
||||
elif command == "emails":
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
action = sys.argv[2].lower()
|
||||
if action == "list":
|
||||
method = "GET"
|
||||
limit = sys.argv[3] if len(sys.argv) >= 4 else "10"
|
||||
path = f"/api/codex/emails?folder=INBOX&limit={limit}&offset=0&filter=all"
|
||||
body = None
|
||||
elif action == "read" and len(sys.argv) >= 4:
|
||||
method = "GET"
|
||||
path = f"/api/codex/emails/{sys.argv[3]}"
|
||||
body = None
|
||||
else:
|
||||
return _usage()
|
||||
else:
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
method = sys.argv[1].upper()
|
||||
path = sys.argv[2]
|
||||
body = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if not path.startswith("/api/codex/"):
|
||||
print("refusing non-/api/codex path; use scoped Odysseus integration endpoints only", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
config = _config()
|
||||
if config is None:
|
||||
return 2
|
||||
base_url, token = config
|
||||
|
||||
data = None
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
if body is not None:
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"invalid json body: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
data = json.dumps(parsed).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
req = urllib.request.Request(base_url + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
print(resp.read().decode("utf-8"))
|
||||
return 0
|
||||
except urllib.error.HTTPError as exc:
|
||||
text = exc.read().decode("utf-8", errors="replace")
|
||||
print(text or f"HTTP {exc.code}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
print(f"request failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "odysseus",
|
||||
"version": "0.1.1",
|
||||
"description": "Connect Codex to a scoped Odysseus instance.",
|
||||
"author": {
|
||||
"name": "Odysseus"
|
||||
},
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Odysseus",
|
||||
"shortDescription": "Use scoped Odysseus tools from Codex.",
|
||||
"longDescription": "Connects Codex terminal sessions to Odysseus through user-controlled scoped API tokens. Codex must use /api/codex/* endpoints so Odysseus Settings can enforce tool access.",
|
||||
"developerName": "Odysseus",
|
||||
"category": "Productivity",
|
||||
"capabilities": [
|
||||
"todos",
|
||||
"email",
|
||||
"scoped-api"
|
||||
],
|
||||
"defaultPrompt": "Use Odysseus only through configured scoped access. Check capabilities before reading or writing data."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Odysseus Codex Integration
|
||||
|
||||
This directory contains the Codex plugin/skill bundle for Odysseus.
|
||||
|
||||
## User Flow
|
||||
|
||||
1. Open Odysseus Settings > Integrations.
|
||||
2. Add a Codex Agent.
|
||||
3. Copy the full setup commands shown after the generated token.
|
||||
4. Toggle the tools Codex is allowed to use.
|
||||
5. Configure the terminal Codex session:
|
||||
|
||||
```bash
|
||||
export ODYSSEUS_URL=http://your-odysseus-host:7000
|
||||
export ODYSSEUS_API_TOKEN=ody_generated_token
|
||||
mkdir -p ~/plugins
|
||||
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
|
||||
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
p = Path.home() / ".agents" / "plugins" / "marketplace.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text())
|
||||
else:
|
||||
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
|
||||
|
||||
data.setdefault("name", "personal")
|
||||
data.setdefault("interface", {}).setdefault("displayName", "Personal")
|
||||
plugins = data.setdefault("plugins", [])
|
||||
entry = {
|
||||
"name": "odysseus",
|
||||
"source": {"source": "local", "path": "./plugins/odysseus"},
|
||||
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
|
||||
"category": "Productivity",
|
||||
}
|
||||
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
|
||||
p.write_text(json.dumps(data, indent=2) + "\n")
|
||||
PY
|
||||
codex plugin add odysseus@personal
|
||||
```
|
||||
|
||||
6. Verify:
|
||||
|
||||
```bash
|
||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities
|
||||
```
|
||||
|
||||
Codex must use `/api/codex/*` endpoints. SSH, Docker, direct Python imports, database queries, and MCP internals bypass Odysseus Settings and must not be used for user data access.
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small Odysseus scoped API helper for Codex terminal sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def _usage() -> int:
|
||||
print("usage:", file=sys.stderr)
|
||||
print(" odysseus_api.py capabilities", file=sys.stderr)
|
||||
print(" odysseus_api.py todos list", file=sys.stderr)
|
||||
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
||||
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
||||
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
||||
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def _config() -> tuple[str, str] | None:
|
||||
base_url = os.environ.get("ODYSSEUS_URL", "").strip().rstrip("/")
|
||||
token = os.environ.get("ODYSSEUS_API_TOKEN", "").strip()
|
||||
missing = []
|
||||
if not base_url:
|
||||
missing.append("ODYSSEUS_URL")
|
||||
if not token:
|
||||
missing.append("ODYSSEUS_API_TOKEN")
|
||||
if missing:
|
||||
print(f"missing {', '.join(missing)}; create a Codex Agent token in Odysseus Settings", file=sys.stderr)
|
||||
return None
|
||||
return base_url, token
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
return _usage()
|
||||
|
||||
command = sys.argv[1].lower()
|
||||
if command == "capabilities":
|
||||
method = "GET"
|
||||
path = "/api/codex/capabilities"
|
||||
body = None
|
||||
elif command == "todos":
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
action = sys.argv[2].lower()
|
||||
path = "/api/codex/todos"
|
||||
if action == "list":
|
||||
method = "GET"
|
||||
body = None
|
||||
elif action == "add" and len(sys.argv) >= 4:
|
||||
method = "POST"
|
||||
body = json.dumps({"action": "add", "title": " ".join(sys.argv[3:])})
|
||||
else:
|
||||
return _usage()
|
||||
elif command == "emails":
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
action = sys.argv[2].lower()
|
||||
if action == "list":
|
||||
method = "GET"
|
||||
limit = sys.argv[3] if len(sys.argv) >= 4 else "10"
|
||||
path = f"/api/codex/emails?folder=INBOX&limit={limit}&offset=0&filter=all"
|
||||
body = None
|
||||
elif action == "read" and len(sys.argv) >= 4:
|
||||
method = "GET"
|
||||
path = f"/api/codex/emails/{sys.argv[3]}"
|
||||
body = None
|
||||
else:
|
||||
return _usage()
|
||||
else:
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
method = sys.argv[1].upper()
|
||||
path = sys.argv[2]
|
||||
body = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if not path.startswith("/api/codex/"):
|
||||
print("refusing non-/api/codex path; use scoped Odysseus integration endpoints only", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
config = _config()
|
||||
if config is None:
|
||||
return 2
|
||||
base_url, token = config
|
||||
|
||||
data = None
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
if body is not None:
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"invalid json body: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
data = json.dumps(parsed).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
req = urllib.request.Request(base_url + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
print(resp.read().decode("utf-8"))
|
||||
return 0
|
||||
except urllib.error.HTTPError as exc:
|
||||
text = exc.read().decode("utf-8", errors="replace")
|
||||
print(text or f"HTTP {exc.code}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
print(f"request failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: odysseus
|
||||
description: Use when the user asks Codex to read or write Odysseus data from a terminal Codex session through the scoped Codex Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||
---
|
||||
|
||||
# Odysseus
|
||||
|
||||
Use this skill when a user asks to interact with Odysseus from Codex.
|
||||
|
||||
## Configuration
|
||||
|
||||
Expect these environment variables:
|
||||
|
||||
- `ODYSSEUS_URL`: Base URL for the user's Odysseus instance, for example `http://127.0.0.1:7000`.
|
||||
- `ODYSSEUS_API_TOKEN`: Scoped API token created in Odysseus Settings > Integrations > Add Integration > Codex Agent.
|
||||
|
||||
If either value is missing, do not guess credentials. Tell the user to create a Codex Agent token in Odysseus Settings and expose both values to the terminal session.
|
||||
|
||||
## When to use what
|
||||
|
||||
- **Reminder ("remind me at 5pm to do X")** → TODO with `due_date`. The due_date IS the reminder — it fires a notification automatically via the user's configured channel (browser/email/ntfy). **Do NOT create a calendar event for a reminder.** Creating a calendar event named "Reminder" does NOT trigger a notification — it's just a time block on the calendar.
|
||||
- **Calendar event ("meeting at 3pm", "dentist Tuesday 10am")** → calendar event. Use for scheduled time blocks, meetings, appointments, recurring schedules. These show up on the calendar grid; reminders for them are configured separately in Odysseus settings.
|
||||
- **Note / freeform info ("note that the wifi password is ...")** → memory or todo without a due_date (depending on whether it's a fact about the user or an action item).
|
||||
- **Persistent fact / preference about the user** → memory.
|
||||
|
||||
If the user says "reminder" + a time, default to TODO with due_date. Only switch to calendar if the user explicitly says "calendar", "event", "meeting", "appointment", or describes a time *range*.
|
||||
|
||||
## Safety
|
||||
|
||||
- All Odysseus data access MUST go through the scoped HTTP API under `/api/codex/*`.
|
||||
- Check `/api/codex/capabilities` before using a tool surface.
|
||||
- Treat `403` as an intentional Settings restriction. Do not work around it.
|
||||
- Do not use SSH, Docker, direct Python imports, SQLite queries, MCP internals, browser cookies, or local files to read/write Odysseus user data.
|
||||
- Do not call helpers like `do_manage_notes`, email MCP internals, or database sessions directly for user data, even if shell access exists.
|
||||
- Never send email directly unless the user explicitly asks to send and the token has a send-capable scope.
|
||||
- Keep actions scoped to the token owner.
|
||||
|
||||
## Todos
|
||||
|
||||
The Codex API supports todos/checklists:
|
||||
|
||||
- `GET /api/codex/todos`
|
||||
- `POST /api/codex/todos`
|
||||
|
||||
Use the bundled helper script when available:
|
||||
|
||||
```bash
|
||||
python3 integrations/codex/scripts/odysseus_api.py capabilities
|
||||
python3 integrations/codex/scripts/odysseus_api.py todos list
|
||||
python3 integrations/codex/scripts/odysseus_api.py todos add "Follow up"
|
||||
```
|
||||
|
||||
Supported todo actions are `list`, `add`, `update`, `delete`, and `toggle_item`.
|
||||
|
||||
**Reminders (todos with a due date)** — the backend parses natural language. Send `due_date` in the body via the generic POST so the time becomes a structured reminder, NOT a literal substring inside the title. The `todos add TITLE` shortcut only sets the title, so use the POST form for anything with a time:
|
||||
|
||||
```bash
|
||||
python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/todos '{"action":"add","title":"Call dentist","due_date":"tomorrow at 5pm"}'
|
||||
```
|
||||
|
||||
The backend accepts both ISO timestamps and natural language like `"tomorrow 5pm"`, `"next Monday 9am"`, `"in 2 hours"`. It anchors to the user's timezone.
|
||||
|
||||
## Email
|
||||
|
||||
The Codex API supports scoped email reads:
|
||||
|
||||
- `GET /api/codex/emails?folder=INBOX&limit=10&offset=0&filter=all`
|
||||
- `GET /api/codex/emails/{uid}?folder=INBOX`
|
||||
|
||||
Use the bundled helper script when available:
|
||||
|
||||
```bash
|
||||
python3 integrations/codex/scripts/odysseus_api.py emails list 5
|
||||
python3 integrations/codex/scripts/odysseus_api.py emails read UID
|
||||
```
|
||||
|
||||
If `/api/codex/capabilities` does not show `email.read: true`, do not inspect email. Ask the user to enable Email read in the Codex Agent settings.
|
||||
|
||||
## Memory
|
||||
|
||||
- `GET /api/codex/memory` — list memories for the token owner.
|
||||
- `POST /api/codex/memory` — body `{"text": "...", "category": "fact", "source": "user", "session_id": null}`. Requires `memory:write`.
|
||||
- `DELETE /api/codex/memory/{memory_id}` — remove a memory entry. Requires `memory:write`.
|
||||
|
||||
```bash
|
||||
python3 integrations/codex/scripts/odysseus_api.py GET /api/codex/memory
|
||||
python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"text":"User prefers SI units","category":"preference"}'
|
||||
```
|
||||
|
||||
## Calendar
|
||||
|
||||
- `GET /api/codex/calendar/events?start=ISO&end=ISO` — list events in window.
|
||||
- `POST /api/codex/calendar/events` — body matches `EventCreate` (`summary`, `dtstart`, `dtend`, `all_day`, `description`, `location`, `calendar_href`, `rrule`, `color`). Requires `calendar:write`.
|
||||
- `DELETE /api/codex/calendar/events/{uid}` — delete event by uid (the value returned in the POST response). Requires `calendar:write`.
|
||||
|
||||
## Documents
|
||||
|
||||
- `GET /api/codex/documents?search=...&limit=50` — paginated library.
|
||||
- `GET /api/codex/documents/{doc_id}` — fetch one document.
|
||||
- `POST /api/codex/documents` — body `{"session_id": "...", "title": "...", "content": "...", "language": "markdown"}`. Requires `documents:write`.
|
||||
- `DELETE /api/codex/documents/{doc_id}` — delete a document. Requires `documents:write`.
|
||||
|
||||
## Email draft + send
|
||||
|
||||
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
|
||||
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
|
||||
|
||||
## Forbidden Bypass Pattern
|
||||
|
||||
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Codex Agent tool toggle instead.
|
||||
+100
-3
@@ -12,6 +12,61 @@ from src.auth_helpers import get_current_user
|
||||
|
||||
MAX_NAME_LEN = 100
|
||||
DEFAULT_SCOPES = "chat"
|
||||
ALLOWED_SCOPES = {
|
||||
"chat",
|
||||
"todos:read",
|
||||
"todos:write",
|
||||
"documents:read",
|
||||
"documents:write",
|
||||
"email:read",
|
||||
"email:draft",
|
||||
"email:send",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
}
|
||||
TOKEN_PROFILES = {
|
||||
"chat": ["chat"],
|
||||
"codex_todos": ["todos:read", "todos:write"],
|
||||
"codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_scopes(scopes: str | list[str] | None = None, profile: str | None = None) -> list[str]:
|
||||
profile = profile if isinstance(profile, str) else None
|
||||
profile_key = (profile or "").strip()
|
||||
if profile_key:
|
||||
if profile_key not in TOKEN_PROFILES:
|
||||
raise HTTPException(400, "Unknown token profile")
|
||||
requested = list(TOKEN_PROFILES[profile_key])
|
||||
elif isinstance(scopes, list):
|
||||
requested = [str(s).strip() for s in scopes if str(s).strip()]
|
||||
elif isinstance(scopes, str) and scopes:
|
||||
requested = [s.strip() for s in scopes.replace(" ", ",").split(",") if s.strip()]
|
||||
else:
|
||||
requested = [DEFAULT_SCOPES]
|
||||
|
||||
normalized = []
|
||||
for scope in requested:
|
||||
if scope not in ALLOWED_SCOPES:
|
||||
raise HTTPException(400, f"Unknown token scope: {scope}")
|
||||
if scope not in normalized:
|
||||
normalized.append(scope)
|
||||
|
||||
def ensure_before(write_scope: str, read_scope: str):
|
||||
if write_scope not in normalized or read_scope in normalized:
|
||||
return
|
||||
idx = normalized.index(write_scope)
|
||||
normalized.insert(idx, read_scope)
|
||||
|
||||
ensure_before("todos:write", "todos:read")
|
||||
ensure_before("documents:write", "documents:read")
|
||||
ensure_before("calendar:write", "calendar:read")
|
||||
ensure_before("memory:write", "memory:read")
|
||||
ensure_before("email:draft", "email:read")
|
||||
|
||||
return normalized or [DEFAULT_SCOPES]
|
||||
|
||||
|
||||
def setup_api_token_routes() -> APIRouter:
|
||||
@@ -45,13 +100,28 @@ def setup_api_token_routes() -> APIRouter:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@router.get("/tokens/profiles")
|
||||
def token_profiles(request: Request):
|
||||
require_admin(request)
|
||||
return {
|
||||
"profiles": TOKEN_PROFILES,
|
||||
"allowed_scopes": sorted(ALLOWED_SCOPES),
|
||||
}
|
||||
|
||||
@router.post("/tokens")
|
||||
def create_token(request: Request, name: str = Form("")):
|
||||
def create_token(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
scopes: str = Form(None),
|
||||
profile: str = Form(None),
|
||||
):
|
||||
require_admin(request)
|
||||
name = name.strip()[:MAX_NAME_LEN]
|
||||
if not name:
|
||||
raise HTTPException(400, "Token name is required")
|
||||
owner = get_current_user(request)
|
||||
scope_list = _normalize_scopes(scopes, profile)
|
||||
scopes_value = ",".join(scope_list)
|
||||
|
||||
raw_token = "ody_" + secrets.token_urlsafe(32)
|
||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||
@@ -64,7 +134,7 @@ def setup_api_token_routes() -> APIRouter:
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=raw_token[:8],
|
||||
scopes=DEFAULT_SCOPES,
|
||||
scopes=scopes_value,
|
||||
is_active=True,
|
||||
))
|
||||
_invalidate_cache(request)
|
||||
@@ -75,9 +145,36 @@ def setup_api_token_routes() -> APIRouter:
|
||||
"owner": owner,
|
||||
"token": raw_token,
|
||||
"token_prefix": raw_token[:8],
|
||||
"scopes": DEFAULT_SCOPES.split(","),
|
||||
"scopes": scope_list,
|
||||
}
|
||||
|
||||
@router.patch("/tokens/{token_id}")
|
||||
async def update_token(request: Request, token_id: str):
|
||||
require_admin(request)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
scope_list = _normalize_scopes(payload.get("scopes"))
|
||||
scopes_value = ",".join(scope_list)
|
||||
with get_db_session() as db:
|
||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||
if not token:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
||||
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
||||
token.scopes = scopes_value
|
||||
db.add(token)
|
||||
response = {
|
||||
"id": token_id,
|
||||
"name": getattr(token, "name", ""),
|
||||
"owner": getattr(token, "owner", None),
|
||||
"token_prefix": getattr(token, "token_prefix", ""),
|
||||
"scopes": scope_list,
|
||||
}
|
||||
_invalidate_cache(request)
|
||||
return response
|
||||
|
||||
@router.delete("/tokens/{token_id}")
|
||||
def delete_token(request: Request, token_id: str):
|
||||
require_admin(request)
|
||||
|
||||
@@ -13,6 +13,7 @@ from dateutil.rrule import DAILY, WEEKLY, MONTHLY, YEARLY
|
||||
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent
|
||||
from src.auth_helpers import get_current_user, require_user
|
||||
from src.upload_limits import read_upload_limited
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -598,12 +599,12 @@ def setup_calendar_routes() -> APIRouter:
|
||||
cfg["username"] = (body.get("username") or "").strip()
|
||||
# Preserve the stored password when the client sends an empty
|
||||
# one (edit form re-submitted without re-typing the password).
|
||||
# cfg already holds the existing (already-encrypted) password from
|
||||
# prefs, so we only touch it when a new password is supplied —
|
||||
# re-encrypting the stored value would double-encrypt it.
|
||||
if body.get("password"):
|
||||
from src.secret_storage import encrypt
|
||||
cfg["password"] = encrypt(body["password"])
|
||||
elif cfg.get("password"):
|
||||
from src.secret_storage import encrypt
|
||||
cfg["password"] = encrypt(cfg["password"])
|
||||
prefs["caldav"] = cfg
|
||||
_save_for_user(owner, prefs)
|
||||
return {"ok": True}
|
||||
@@ -1005,9 +1006,7 @@ def setup_calendar_routes() -> APIRouter:
|
||||
owner = _require_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
content = await file.read()
|
||||
if len(content) > _ICS_MAX_BYTES:
|
||||
raise HTTPException(413, f"ICS file too large (max {_ICS_MAX_BYTES // (1024*1024)} MB)")
|
||||
content = await read_upload_limited(file, _ICS_MAX_BYTES, "ICS file")
|
||||
try:
|
||||
cal_data = iCal.from_ical(content)
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Codex integration routes.
|
||||
|
||||
These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They
|
||||
reuse existing Odysseus helpers and enforce API-token scopes before touching
|
||||
user data.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from src.auth_helpers import require_user
|
||||
from src.tool_implementations import do_manage_notes
|
||||
|
||||
|
||||
TODO_READ_SCOPES = {"todos:read", "todos:write"}
|
||||
TODO_WRITE_SCOPES = {"todos:write"}
|
||||
EMAIL_READ_SCOPES = {"email:read", "email:draft", "email:send"}
|
||||
EMAIL_DRAFT_SCOPES = {"email:draft", "email:send"}
|
||||
EMAIL_SEND_SCOPES = {"email:send"}
|
||||
MEMORY_READ_SCOPES = {"memory:read", "memory:write"}
|
||||
MEMORY_WRITE_SCOPES = {"memory:write"}
|
||||
CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"}
|
||||
CALENDAR_WRITE_SCOPES = {"calendar:write"}
|
||||
DOCS_READ_SCOPES = {"documents:read", "documents:write"}
|
||||
DOCS_WRITE_SCOPES = {"documents:write"}
|
||||
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
|
||||
|
||||
|
||||
async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
"""Run an existing route handler with request.state.current_user temporarily
|
||||
set to ``owner`` so its internal get_current_user/require_user calls see
|
||||
the scope-gated owner (not the "api" pseudo-user the bearer middleware sets).
|
||||
Restores the original value when done. Works for sync and async handlers."""
|
||||
orig = getattr(request.state, "current_user", None)
|
||||
request.state.current_user = owner
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
return result
|
||||
finally:
|
||||
request.state.current_user = orig
|
||||
|
||||
|
||||
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
"""Return the data owner if the caller is allowed for this Codex action."""
|
||||
if getattr(request.state, "api_token", False):
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if not scopes.intersection(allowed):
|
||||
required = " or ".join(sorted(allowed))
|
||||
raise HTTPException(403, f"API token missing required scope: {required}")
|
||||
owner = getattr(request.state, "api_token_owner", None)
|
||||
if not owner:
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
return owner
|
||||
return require_user(request)
|
||||
|
||||
|
||||
def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
||||
if router is None:
|
||||
return None
|
||||
for route in getattr(router, "routes", []):
|
||||
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
return None
|
||||
|
||||
|
||||
def setup_codex_routes(
|
||||
email_router: APIRouter | None = None,
|
||||
memory_router: APIRouter | None = None,
|
||||
calendar_router: APIRouter | None = None,
|
||||
document_router: APIRouter | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/codex", tags=["codex"])
|
||||
email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
|
||||
email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}")
|
||||
email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send")
|
||||
email_draft_endpoint = _find_endpoint(email_router, "POST", "/api/email/draft")
|
||||
memory_list_endpoint = _find_endpoint(memory_router, "GET", "/api/memory")
|
||||
memory_add_endpoint = _find_endpoint(memory_router, "POST", "/api/memory/add")
|
||||
calendar_list_events = _find_endpoint(calendar_router, "GET", "/api/calendar/events")
|
||||
calendar_create_event = _find_endpoint(calendar_router, "POST", "/api/calendar/events")
|
||||
documents_library_endpoint = _find_endpoint(document_router, "GET", "/api/documents/library")
|
||||
documents_get_endpoint = _find_endpoint(document_router, "GET", "/api/document/{doc_id}")
|
||||
documents_create_endpoint = _find_endpoint(document_router, "POST", "/api/document")
|
||||
|
||||
@router.get("/capabilities")
|
||||
def capabilities(request: Request):
|
||||
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
has_token = bool(getattr(request.state, "api_token", False))
|
||||
def scoped(allowed):
|
||||
return bool(token_scopes.intersection(allowed)) if has_token else True
|
||||
return {
|
||||
"integration": "codex",
|
||||
"token_scopes": sorted(token_scopes),
|
||||
"tools": {
|
||||
"todos": {
|
||||
"read": scoped(TODO_READ_SCOPES),
|
||||
"write": scoped(TODO_WRITE_SCOPES),
|
||||
"actions": ["list", "add", "update", "delete", "toggle_item"],
|
||||
},
|
||||
"email": {
|
||||
"read": scoped(EMAIL_READ_SCOPES),
|
||||
"draft": scoped(EMAIL_DRAFT_SCOPES),
|
||||
"send": scoped(EMAIL_SEND_SCOPES),
|
||||
"actions": ["list", "read", "draft", "send"],
|
||||
},
|
||||
"memory": {
|
||||
"read": scoped(MEMORY_READ_SCOPES),
|
||||
"write": scoped(MEMORY_WRITE_SCOPES),
|
||||
"actions": ["list", "add", "delete"],
|
||||
"available": memory_list_endpoint is not None,
|
||||
},
|
||||
"calendar": {
|
||||
"read": scoped(CALENDAR_READ_SCOPES),
|
||||
"write": scoped(CALENDAR_WRITE_SCOPES),
|
||||
"actions": ["list_events", "create_event", "delete_event"],
|
||||
"available": calendar_list_events is not None,
|
||||
},
|
||||
"documents": {
|
||||
"read": scoped(DOCS_READ_SCOPES),
|
||||
"write": scoped(DOCS_WRITE_SCOPES),
|
||||
"actions": ["library", "read", "create", "delete"],
|
||||
"available": documents_library_endpoint is not None,
|
||||
},
|
||||
},
|
||||
"safety": {
|
||||
"email_send_requires_confirmation": True,
|
||||
"destructive_actions_should_confirm": True,
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_user(request)
|
||||
root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
|
||||
if not root.exists():
|
||||
raise HTTPException(404, "Codex plugin bundle not found")
|
||||
buf = BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_dir() or "__pycache__" in path.parts or path.suffix == ".pyc":
|
||||
continue
|
||||
zf.write(path, Path("odysseus") / path.relative_to(root))
|
||||
buf.seek(0)
|
||||
headers = {"Content-Disposition": 'attachment; filename="odysseus-codex-plugin.zip"'}
|
||||
return StreamingResponse(buf, media_type="application/zip", headers=headers)
|
||||
|
||||
@router.get("/todos")
|
||||
async def list_todos(request: Request, archived: bool = False, label: str | None = None):
|
||||
owner = _scope_owner(request, TODO_READ_SCOPES)
|
||||
args: dict[str, Any] = {"action": "list", "archived": archived}
|
||||
if label:
|
||||
args["label"] = label
|
||||
return await do_manage_notes(json.dumps(args), owner=owner)
|
||||
|
||||
@router.post("/todos")
|
||||
async def manage_todos(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
action = str(body.get("action") or "add").replace("-", "_").strip().lower()
|
||||
allowed = TODO_WRITE_SCOPES if action in WRITE_ACTIONS else TODO_READ_SCOPES
|
||||
owner = _scope_owner(request, allowed)
|
||||
args = dict(body)
|
||||
args["action"] = action
|
||||
return await do_manage_notes(json.dumps(args), owner=owner)
|
||||
|
||||
@router.get("/emails")
|
||||
async def list_emails(
|
||||
request: Request,
|
||||
folder: str = "INBOX",
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
filter: str = "all",
|
||||
from_addr: str | None = None,
|
||||
account_id: str | None = None,
|
||||
has_attachments: int = 0,
|
||||
):
|
||||
owner = _scope_owner(request, EMAIL_READ_SCOPES)
|
||||
if email_list_endpoint is None:
|
||||
raise HTTPException(503, "Email integration is not available")
|
||||
limit = max(1, min(int(limit or 10), 50))
|
||||
offset = max(0, int(offset or 0))
|
||||
if account_id:
|
||||
from routes.email_helpers import _assert_owns_account
|
||||
|
||||
_assert_owns_account(account_id, owner)
|
||||
return await email_list_endpoint(
|
||||
folder=folder,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filter=filter,
|
||||
from_addr=from_addr,
|
||||
account_id=account_id,
|
||||
has_attachments=has_attachments,
|
||||
cache_bust=None,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
@router.get("/emails/{uid}")
|
||||
async def read_email(
|
||||
request: Request,
|
||||
uid: str,
|
||||
folder: str = "INBOX",
|
||||
account_id: str | None = None,
|
||||
mark_seen: bool = False,
|
||||
):
|
||||
owner = _scope_owner(request, EMAIL_READ_SCOPES)
|
||||
if email_read_endpoint is None:
|
||||
raise HTTPException(503, "Email integration is not available")
|
||||
if account_id:
|
||||
from routes.email_helpers import _assert_owns_account
|
||||
|
||||
_assert_owns_account(account_id, owner)
|
||||
return await email_read_endpoint(
|
||||
uid=uid,
|
||||
folder=folder,
|
||||
account_id=account_id,
|
||||
mark_seen=mark_seen,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
# ── Email draft + send ────────────────────────────────────────────────
|
||||
# Both handlers in routes/email_routes.py already accept `owner=` via
|
||||
# FastAPI Depends, so we call them directly without patching state.
|
||||
|
||||
@router.post("/emails/draft")
|
||||
async def codex_email_draft(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, EMAIL_DRAFT_SCOPES)
|
||||
if email_draft_endpoint is None:
|
||||
raise HTTPException(503, "Email integration is not available")
|
||||
from routes.email_routes import SendEmailRequest
|
||||
|
||||
try:
|
||||
req = SendEmailRequest(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid draft payload: {exc}")
|
||||
return await email_draft_endpoint(req=req, owner=owner)
|
||||
|
||||
@router.post("/emails/send")
|
||||
async def codex_email_send(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, EMAIL_SEND_SCOPES)
|
||||
if email_send_endpoint is None:
|
||||
raise HTTPException(503, "Email integration is not available")
|
||||
from routes.email_routes import SendEmailRequest
|
||||
|
||||
try:
|
||||
req = SendEmailRequest(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid send payload: {exc}")
|
||||
return await email_send_endpoint(req=req, background_tasks=BackgroundTasks(), owner=owner)
|
||||
|
||||
# ── Memory ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/memory")
|
||||
async def codex_memory_list(request: Request):
|
||||
owner = _scope_owner(request, MEMORY_READ_SCOPES)
|
||||
if memory_list_endpoint is None:
|
||||
raise HTTPException(503, "Memory integration is not available")
|
||||
return await _as_owner(request, owner, memory_list_endpoint, request)
|
||||
|
||||
@router.post("/memory")
|
||||
async def codex_memory_add(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, MEMORY_WRITE_SCOPES)
|
||||
if memory_add_endpoint is None:
|
||||
raise HTTPException(503, "Memory integration is not available")
|
||||
from src.request_models import MemoryAddRequest
|
||||
|
||||
try:
|
||||
memory_data = MemoryAddRequest(
|
||||
text=str(body.get("text") or "").strip(),
|
||||
category=body.get("category", "fact"),
|
||||
source=body.get("source", "user"),
|
||||
session_id=body.get("session_id"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid memory payload: {exc}")
|
||||
if not memory_data.text:
|
||||
raise HTTPException(400, "Empty memory text")
|
||||
return await _as_owner(request, owner, memory_add_endpoint, request, memory_data)
|
||||
|
||||
# ── Calendar ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/calendar/events")
|
||||
async def codex_calendar_list(request: Request, start: str, end: str, calendar: str = ""):
|
||||
owner = _scope_owner(request, CALENDAR_READ_SCOPES)
|
||||
if calendar_list_events is None:
|
||||
raise HTTPException(503, "Calendar integration is not available")
|
||||
return await _as_owner(request, owner, calendar_list_events, request, start, end, calendar)
|
||||
|
||||
@router.post("/calendar/events")
|
||||
async def codex_calendar_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, CALENDAR_WRITE_SCOPES)
|
||||
if calendar_create_event is None:
|
||||
raise HTTPException(503, "Calendar integration is not available")
|
||||
from routes.calendar_routes import EventCreate
|
||||
|
||||
try:
|
||||
data = EventCreate(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid event payload: {exc}")
|
||||
return await _as_owner(request, owner, calendar_create_event, request, data)
|
||||
|
||||
# ── Documents ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/documents")
|
||||
async def codex_documents_library(
|
||||
request: Request,
|
||||
search: str | None = None,
|
||||
language: str | None = None,
|
||||
sort: str = "recent",
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
archived: bool = False,
|
||||
):
|
||||
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
||||
if documents_library_endpoint is None:
|
||||
raise HTTPException(503, "Documents integration is not available")
|
||||
return await _as_owner(
|
||||
request, owner, documents_library_endpoint,
|
||||
request, search, language, sort, offset, limit, archived,
|
||||
)
|
||||
|
||||
@router.get("/documents/{doc_id}")
|
||||
async def codex_documents_get(request: Request, doc_id: str):
|
||||
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
||||
if documents_get_endpoint is None:
|
||||
raise HTTPException(503, "Documents integration is not available")
|
||||
return await _as_owner(request, owner, documents_get_endpoint, request, doc_id)
|
||||
|
||||
# ── DELETE endpoints so agents can clean up after themselves ──────────
|
||||
|
||||
memory_delete_endpoint = _find_endpoint(memory_router, "DELETE", "/api/memory/{memory_id}")
|
||||
calendar_delete_event = _find_endpoint(calendar_router, "DELETE", "/api/calendar/events/{uid}")
|
||||
documents_delete_endpoint = _find_endpoint(document_router, "DELETE", "/api/document/{doc_id}")
|
||||
|
||||
@router.delete("/memory/{memory_id}")
|
||||
async def codex_memory_delete(request: Request, memory_id: str):
|
||||
owner = _scope_owner(request, MEMORY_WRITE_SCOPES)
|
||||
if memory_delete_endpoint is None:
|
||||
raise HTTPException(503, "Memory delete not available")
|
||||
return await _as_owner(request, owner, memory_delete_endpoint, request, memory_id)
|
||||
|
||||
@router.delete("/calendar/events/{uid}")
|
||||
async def codex_calendar_delete(request: Request, uid: str):
|
||||
owner = _scope_owner(request, CALENDAR_WRITE_SCOPES)
|
||||
if calendar_delete_event is None:
|
||||
raise HTTPException(503, "Calendar delete not available")
|
||||
return await _as_owner(request, owner, calendar_delete_event, request, uid)
|
||||
|
||||
@router.delete("/documents/{doc_id}")
|
||||
async def codex_documents_delete(request: Request, doc_id: str):
|
||||
owner = _scope_owner(request, DOCS_WRITE_SCOPES)
|
||||
if documents_delete_endpoint is None:
|
||||
raise HTTPException(503, "Documents delete not available")
|
||||
return await _as_owner(request, owner, documents_delete_endpoint, request, doc_id)
|
||||
|
||||
@router.post("/documents")
|
||||
async def codex_documents_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, DOCS_WRITE_SCOPES)
|
||||
if documents_create_endpoint is None:
|
||||
raise HTTPException(503, "Documents integration is not available")
|
||||
from routes.document_routes import DocumentCreate
|
||||
|
||||
try:
|
||||
req = DocumentCreate(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid document payload: {exc}")
|
||||
return await _as_owner(request, owner, documents_create_endpoint, request, req)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def setup_claude_routes() -> APIRouter:
|
||||
"""Serve the Claude Code skill bundle.
|
||||
|
||||
Claude Code uses the same scope-gated `/api/codex/*` endpoints at runtime;
|
||||
this router only exists to deliver the skill zip via `/api/claude/plugin.zip`
|
||||
so the user-facing setup commands stay in the Claude namespace.
|
||||
"""
|
||||
router = APIRouter(prefix="/api/claude", tags=["claude"])
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_user(request)
|
||||
# Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump
|
||||
# README.md or other bundle metadata into the user's claude config dir.
|
||||
skills_root = Path(__file__).resolve().parent.parent / "integrations" / "claude" / "skills"
|
||||
if not skills_root.exists():
|
||||
raise HTTPException(404, "Claude skill bundle not found")
|
||||
bundle_root = skills_root.parent
|
||||
buf = BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in sorted(skills_root.rglob("*")):
|
||||
if path.is_dir() or "__pycache__" in path.parts or path.suffix == ".pyc":
|
||||
continue
|
||||
zf.write(path, path.relative_to(bundle_root))
|
||||
buf.seek(0)
|
||||
headers = {"Content-Disposition": 'attachment; filename="odysseus-claude-skill.zip"'}
|
||||
return StreamingResponse(buf, media_type="application/zip", headers=headers)
|
||||
|
||||
return router
|
||||
@@ -18,6 +18,26 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/compare", tags=["compare"])
|
||||
|
||||
|
||||
def _owned_endpoint_by_url(db, base_url, owner):
|
||||
"""ModelEndpoint whose base_url == `base_url` and is VISIBLE to `owner`
|
||||
(their own rows + legacy null-owner "shared" rows); None otherwise.
|
||||
|
||||
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
|
||||
owner = private, "the model picker only shows the endpoint to that user") and
|
||||
holds a decrypted `api_key`. start_comparison copies the matched row's api_key
|
||||
into the caller-owned [CMP] session's headers, which then drives that session's
|
||||
/api/chat_stream calls — so an UNSCOPED base_url match would let a user mint a
|
||||
comparison bound to ANOTHER user's private endpoint and spend that owner's
|
||||
api_key / reach whatever base_url they configured. Mirrors
|
||||
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
|
||||
legacy mode).
|
||||
"""
|
||||
from core.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url)
|
||||
return owner_filter(q, ModelEndpoint, owner).first()
|
||||
|
||||
|
||||
class RecordVoteRequest(BaseModel):
|
||||
prompt: str
|
||||
models: List[str]
|
||||
@@ -61,13 +81,11 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
# Copy API key from endpoint config
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from core.database import ModelEndpoint
|
||||
from src.endpoint_resolver import build_headers, normalize_base
|
||||
# Find matching endpoint by URL
|
||||
# Find matching endpoint by URL, scoped to the caller so a
|
||||
# comparison can't borrow another user's private endpoint key.
|
||||
base = normalize_base(endpoint)
|
||||
ep = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.base_url == base
|
||||
).first()
|
||||
ep = _owned_endpoint_by_url(db, base, user)
|
||||
if ep and ep.api_key:
|
||||
s = session_manager.sessions.get(sid)
|
||||
if s:
|
||||
|
||||
@@ -34,6 +34,7 @@ from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPExc
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from src.llm_core import llm_call_async
|
||||
from src.upload_limits import read_upload_limited
|
||||
|
||||
from routes.email_helpers import (
|
||||
_strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account,
|
||||
@@ -55,6 +56,7 @@ from routes.email_pollers import _start_poller
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
|
||||
EMAIL_COMPOSE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
|
||||
@@ -1880,16 +1882,12 @@ def setup_email_routes():
|
||||
@router.post("/compose-upload")
|
||||
async def compose_upload(file: UploadFile = File(...), owner: str = Depends(require_owner)):
|
||||
"""Upload a file for attaching to a compose email. Returns a token."""
|
||||
# 25MB cap (matches typical SMTP limits w/ base64 overhead)
|
||||
MAX_BYTES = 25 * 1024 * 1024
|
||||
try:
|
||||
# Sanitize filename and generate a unique token
|
||||
safe_name = re.sub(r"[^\w\s\-.]", "_", file.filename or "file").strip()
|
||||
token = f"{uuid.uuid4().hex}_{safe_name}"
|
||||
filepath = COMPOSE_UPLOADS_DIR / token
|
||||
content = await file.read()
|
||||
if len(content) > MAX_BYTES:
|
||||
raise HTTPException(413, f"Attachment exceeds {MAX_BYTES // (1024*1024)}MB limit")
|
||||
content = await read_upload_limited(file, EMAIL_COMPOSE_UPLOAD_MAX_BYTES, "Attachment")
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(content)
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, require_privilege
|
||||
from src.upload_limits import read_upload_limited
|
||||
|
||||
from routes.gallery_helpers import (
|
||||
GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size,
|
||||
@@ -20,6 +21,9 @@ from routes.gallery_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GALLERY_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES", str(100 * 1024 * 1024)))
|
||||
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024)))
|
||||
|
||||
|
||||
def _sanitize_gallery_filename(filename: str) -> str:
|
||||
"""Return a local filename safe to join under generated_images."""
|
||||
@@ -45,7 +49,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
user = get_current_user(request)
|
||||
album_id = form.get("album_id") or None
|
||||
content = await file.read()
|
||||
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery upload")
|
||||
|
||||
# Duplicate detection via SHA-256
|
||||
file_hash = hashlib.sha256(content).hexdigest()
|
||||
@@ -130,7 +134,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not file or not hasattr(file, 'read'):
|
||||
raise HTTPException(400, "No image provided")
|
||||
|
||||
content = await file.read()
|
||||
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery replacement")
|
||||
img_dir = Path("data/generated_images")
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
img_path = img_dir / _sanitize_gallery_filename(img.filename)
|
||||
@@ -250,7 +254,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not file: raise HTTPException(400, "No image")
|
||||
scale = int(form.get("scale", "2"))
|
||||
|
||||
image_bytes = await file.read()
|
||||
image_bytes = await read_upload_limited(file, GALLERY_TRANSFORM_UPLOAD_MAX_BYTES, "Image upload")
|
||||
b64 = base64.b64encode(image_bytes).decode()
|
||||
|
||||
# Find image endpoint
|
||||
@@ -294,7 +298,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
strength = float(form.get("strength", "0.55"))
|
||||
if not file: raise HTTPException(400, "No image")
|
||||
|
||||
image_bytes = await file.read()
|
||||
image_bytes = await read_upload_limited(file, GALLERY_TRANSFORM_UPLOAD_MAX_BYTES, "Image upload")
|
||||
b64 = base64.b64encode(image_bytes).decode()
|
||||
|
||||
db = SessionLocal()
|
||||
@@ -1804,4 +1808,3 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
+22
-5
@@ -29,9 +29,12 @@ from src.llm_core import llm_call_async
|
||||
from services.memory.memory_extractor import audit_memories
|
||||
from src.auth_helpers import get_current_user, require_user
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.upload_limits import read_upload_limited
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEMORY_IMPORT_MAX_BYTES = int(os.getenv("ODYSSEUS_MEMORY_IMPORT_MAX_BYTES", str(10 * 1024 * 1024)))
|
||||
|
||||
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
||||
"""Set up memory-related routes."""
|
||||
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
||||
@@ -39,6 +42,18 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
def _owner(request: Request) -> Optional[str]:
|
||||
return get_current_user(request)
|
||||
|
||||
def _assert_session_owner(session_obj, user):
|
||||
"""SECURITY: 404 if the caller does not own this session.
|
||||
|
||||
SessionManager.get_session is NOT owner-scoped — it returns any
|
||||
session by id. These routes accept a caller-supplied session id, so
|
||||
without this gate a user could target another tenant's session and
|
||||
leak their chat history, their session-scoped LLM credentials, or the
|
||||
session title. Mirrors session_routes / webhook_routes ownership.
|
||||
"""
|
||||
if user is not None and getattr(session_obj, "owner", None) != user:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
def _verify_memory_owner(memory: dict, user: Optional[str]):
|
||||
"""Raise 404 if user doesn't own this memory.
|
||||
|
||||
@@ -161,12 +176,12 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
@router.get("/by-session/{session_id}")
|
||||
def get_memory_by_session(request: Request, session_id: str):
|
||||
"""Get all memories associated with a specific session."""
|
||||
user = _owner(request)
|
||||
try:
|
||||
session_manager.get_session(session_id)
|
||||
_session_obj = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, f"Session {session_id} not found")
|
||||
|
||||
user = _owner(request)
|
||||
_assert_session_owner(_session_obj, user)
|
||||
memories = memory_manager.load(owner=user)
|
||||
session_memories = [m for m in memories if m.get("session_id") == session_id]
|
||||
|
||||
@@ -196,6 +211,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
sess = session_manager.get_session(session)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
_assert_session_owner(sess, _owner(request))
|
||||
|
||||
system_msg = {
|
||||
"role": "system",
|
||||
@@ -277,6 +293,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
if not endpoint_url and session:
|
||||
try:
|
||||
sess = session_manager.get_session(session)
|
||||
_assert_session_owner(sess, _owner(request))
|
||||
endpoint_url = sess.endpoint_url
|
||||
model = sess.model
|
||||
headers = sess.headers
|
||||
@@ -327,6 +344,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
if session:
|
||||
try:
|
||||
sess = session_manager.get_session(session)
|
||||
_assert_session_owner(sess, _owner(request))
|
||||
endpoint_url = sess.endpoint_url
|
||||
model = sess.model
|
||||
headers = sess.headers
|
||||
@@ -338,8 +356,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
if not endpoint_url or not model:
|
||||
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
content = await read_upload_limited(file, MEMORY_IMPORT_MAX_BYTES, "Memory import")
|
||||
filename = file.filename or "upload"
|
||||
_, ext = os.path.splitext(filename.lower())
|
||||
|
||||
|
||||
@@ -48,6 +48,30 @@ def _resolve_research_endpoint(sess) -> tuple:
|
||||
return url, model, headers
|
||||
|
||||
|
||||
def _owned_enabled_endpoint(db, owner, endpoint_id=None):
|
||||
"""An enabled ModelEndpoint VISIBLE to `owner` (their own rows + legacy
|
||||
null-owner "shared" rows), optionally narrowed to a specific endpoint_id;
|
||||
None if nothing visible matches.
|
||||
|
||||
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
|
||||
owner = private, "the model picker only shows the endpoint to that user") and
|
||||
holds a decrypted `api_key`. /api/research/start feeds the resolved row's
|
||||
api_key + base_url into research_handler.start_research(llm_endpoint=,
|
||||
llm_headers=), so an UNSCOPED lookup — by the caller-supplied endpoint_id, or
|
||||
via the bare first-enabled fallback — would let a research-privileged user
|
||||
spend ANOTHER user's API key/quota and reach whatever internal base_url they
|
||||
configured. Mirrors webhook_routes._first_enabled_endpoint and
|
||||
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
|
||||
legacy mode).
|
||||
"""
|
||||
from src.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||
if endpoint_id:
|
||||
q = q.filter(ModelEndpoint.id == endpoint_id)
|
||||
return owner_filter(q, ModelEndpoint, owner).first()
|
||||
|
||||
|
||||
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
router = APIRouter(tags=["research"])
|
||||
|
||||
@@ -344,14 +368,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
|
||||
if body.endpoint_id:
|
||||
from src.database import SessionLocal
|
||||
from src.database import ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id == body.endpoint_id,
|
||||
ModelEndpoint.is_enabled == True,
|
||||
).first()
|
||||
# Owner-scoped: never resolve another user's private endpoint
|
||||
# (and its decrypted api_key / internal base_url). A scoped miss
|
||||
# reads as 404 so the endpoint's existence isn't revealed.
|
||||
ep = _owned_enabled_endpoint(db, user, body.endpoint_id)
|
||||
if not ep:
|
||||
raise HTTPException(404, "Endpoint not found or disabled")
|
||||
base = normalize_base(ep.base_url)
|
||||
@@ -382,13 +405,14 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
ep_url, ep_model, ep_headers = resolve_endpoint("chat")
|
||||
if not ep_url:
|
||||
from src.database import SessionLocal
|
||||
from src.database import ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True,
|
||||
).first()
|
||||
# Owner-scoped first-enabled fallback: the caller's own rows
|
||||
# + legacy null-owner shared rows only — never borrow another
|
||||
# user's private endpoint/api_key. Same fix as the
|
||||
# /api/v1/chat fallback (webhook_routes._first_enabled_endpoint).
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
ep_url = build_chat_url(base)
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
import logging
|
||||
|
||||
from src.upload_limits import read_upload_limited
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def setup_stt_routes(stt_service):
|
||||
"""Setup STT routes with the provided STT service"""
|
||||
@@ -30,7 +34,7 @@ def setup_stt_routes(stt_service):
|
||||
detail={"message": "STT service not available or set to browser mode"}
|
||||
)
|
||||
|
||||
audio_bytes = await file.read()
|
||||
audio_bytes = await read_upload_limited(file, STT_MAX_AUDIO_BYTES, "Audio file")
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail={"message": "Empty audio file"})
|
||||
|
||||
|
||||
@@ -446,7 +446,6 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
"summarize_emails": ("email_summaries",),
|
||||
"draft_email_replies": ("email_ai_replies",),
|
||||
"extract_email_events": ("email_calendar_extractions",),
|
||||
"mark_email_boundaries": ("email_boundaries",),
|
||||
"learn_sender_signatures": ("sender_signatures",),
|
||||
"check_email_urgency": ("email_tags", "email_urgency_alerts"),
|
||||
}
|
||||
|
||||
@@ -39,7 +39,17 @@ _PRIVATE_NETWORKS = (
|
||||
|
||||
|
||||
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
|
||||
return addr.is_private or addr.is_loopback or addr.is_link_local or any(addr in net for net in _PRIVATE_NETWORKS)
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return (
|
||||
addr.is_private
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_reserved
|
||||
or addr.is_multicast
|
||||
or addr.is_unspecified
|
||||
or any(addr in net for net in _PRIVATE_NETWORKS)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
|
||||
|
||||
+34
-23
@@ -58,7 +58,7 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None):
|
||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url, build_headers, build_models_url
|
||||
|
||||
|
||||
def _resolve_model(spec: str) -> Tuple[str, str, Dict]:
|
||||
def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]:
|
||||
"""Resolve a model specifier to (endpoint_url, model_id, headers).
|
||||
|
||||
Accepts:
|
||||
@@ -70,6 +70,7 @@ def _resolve_model(spec: str) -> Tuple[str, str, Dict]:
|
||||
import httpx
|
||||
from src.database import SessionLocal, ModelEndpoint
|
||||
from src.llm_core import _detect_provider, ANTHROPIC_MODELS
|
||||
from src.auth_helpers import owner_filter
|
||||
|
||||
spec = spec.strip()
|
||||
target_endpoint_name = None
|
||||
@@ -86,6 +87,8 @@ def _resolve_model(spec: str) -> Tuple[str, str, Dict]:
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if target_endpoint_name:
|
||||
query = query.filter(ModelEndpoint.name.ilike(f"%{target_endpoint_name}%"))
|
||||
if owner:
|
||||
query = owner_filter(query, ModelEndpoint, owner)
|
||||
endpoints = query.all()
|
||||
|
||||
if not endpoints:
|
||||
@@ -141,7 +144,7 @@ def _resolve_model(spec: str) -> Tuple[str, str, Dict]:
|
||||
# Tool implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_chat_with_model(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
async def do_chat_with_model(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Send a message to a specific model and return its response.
|
||||
|
||||
Content format:
|
||||
@@ -160,7 +163,7 @@ async def do_chat_with_model(content: str, session_id: Optional[str] = None) ->
|
||||
return {"error": "No message provided (line 2+ is the message)"}
|
||||
|
||||
try:
|
||||
url, model, headers = _resolve_model(model_spec)
|
||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -190,7 +193,7 @@ _TEACHER_SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
async def do_ask_teacher(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
async def do_ask_teacher(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Ask a more capable model for help.
|
||||
|
||||
Content format:
|
||||
@@ -213,7 +216,7 @@ async def do_ask_teacher(content: str, session_id: Optional[str] = None) -> Dict
|
||||
return {"error": "No teacher model configured. Specify a model name or set teacher_model in settings."}
|
||||
|
||||
try:
|
||||
url, model, headers = _resolve_model(model_spec)
|
||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -235,7 +238,7 @@ async def do_ask_teacher(content: str, session_id: Optional[str] = None) -> Dict
|
||||
return {"error": f"Teacher call failed ({model_spec}): {e}"}
|
||||
|
||||
|
||||
async def do_second_opinion(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
async def do_second_opinion(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Get a second opinion from another model, then have the original model
|
||||
evaluate the feedback and produce a unified version.
|
||||
|
||||
@@ -259,7 +262,7 @@ async def do_second_opinion(content: str, session_id: Optional[str] = None) -> D
|
||||
focus = lines[1].strip() if len(lines) > 1 else ""
|
||||
|
||||
try:
|
||||
reviewer_url, reviewer_model, reviewer_headers = _resolve_model(model_spec)
|
||||
reviewer_url, reviewer_model, reviewer_headers = _resolve_model(model_spec, owner=owner)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -400,7 +403,7 @@ async def do_create_session(content: str, session_id: Optional[str] = None, owne
|
||||
return {"error": "Session name cannot be empty"}
|
||||
|
||||
try:
|
||||
url, model, headers = _resolve_model(model_spec)
|
||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -584,7 +587,7 @@ async def stream_ai_tool(tool: str, content: str, session_id: Optional[str] = No
|
||||
yield {"_final": True, "desc": desc, "result": result}
|
||||
|
||||
|
||||
async def do_pipeline(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Execute a multi-step pipeline where each model's output feeds the next.
|
||||
|
||||
Content format (JSON):
|
||||
@@ -638,7 +641,7 @@ async def do_pipeline(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
if not model_spec or not instruction:
|
||||
return {"error": f"Step {i + 1}: both 'model' and 'instruction' are required"}
|
||||
try:
|
||||
url, model, headers = _resolve_model(model_spec)
|
||||
url, model, headers = _resolve_model(model_spec, owner=owner)
|
||||
resolved.append((url, model, headers, instruction))
|
||||
except ValueError as e:
|
||||
return {"error": f"Step {i + 1}: {e}"}
|
||||
@@ -1091,7 +1094,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
# List models tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_list_models(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
async def do_list_models(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""List all available models across configured endpoints.
|
||||
|
||||
Content = optional filter keyword.
|
||||
@@ -1099,12 +1102,16 @@ async def do_list_models(content: str, session_id: Optional[str] = None) -> Dict
|
||||
import httpx
|
||||
from src.database import SessionLocal, ModelEndpoint
|
||||
from src.llm_core import _detect_provider, ANTHROPIC_MODELS
|
||||
from src.auth_helpers import owner_filter
|
||||
|
||||
keyword = content.strip().lower() if content.strip() else None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner:
|
||||
query = owner_filter(query, ModelEndpoint, owner)
|
||||
endpoints = query.all()
|
||||
if not endpoints:
|
||||
return {"results": "No enabled model endpoints configured."}
|
||||
|
||||
@@ -1250,7 +1257,7 @@ async def do_manage_rag(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
# UI control tool (returns events for frontend to apply)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_ui_control(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
async def do_ui_control(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Control frontend UI: toggle settings, switch model, change theme.
|
||||
|
||||
Content format:
|
||||
@@ -1325,7 +1332,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None) -> Dict:
|
||||
|
||||
# Resolve the model to validate it exists
|
||||
try:
|
||||
url, model_id, headers = _resolve_model(model_spec)
|
||||
url, model_id, headers = _resolve_model(model_spec, owner=owner)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -1580,7 +1587,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
if not model_spec:
|
||||
for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"):
|
||||
try:
|
||||
_resolve_model(candidate)
|
||||
_resolve_model(candidate, owner=owner)
|
||||
model_spec = candidate
|
||||
break
|
||||
except ValueError:
|
||||
@@ -1589,13 +1596,17 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
if not model_spec:
|
||||
try:
|
||||
from src.database import SessionLocal, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
import httpx as _req
|
||||
_idb = SessionLocal()
|
||||
try:
|
||||
_img_eps = _idb.query(ModelEndpoint).filter(
|
||||
_img_q = _idb.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True,
|
||||
ModelEndpoint.model_type == "image",
|
||||
).all()
|
||||
)
|
||||
if owner:
|
||||
_img_q = owner_filter(_img_q, ModelEndpoint, owner)
|
||||
_img_eps = _img_q.all()
|
||||
for _iep in _img_eps:
|
||||
_ibase = _iep.base_url.rstrip("/")
|
||||
if not _ibase.endswith("/v1"):
|
||||
@@ -1618,7 +1629,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
|
||||
# Resolve the model to find the right endpoint
|
||||
try:
|
||||
url, model_id, headers = _resolve_model(model_spec)
|
||||
url, model_id, headers = _resolve_model(model_spec, owner=owner)
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'. "
|
||||
"Configure an OpenAI-compatible endpoint with image generation support."}
|
||||
@@ -1760,7 +1771,7 @@ async def dispatch_ai_tool(
|
||||
if tool == "chat_with_model":
|
||||
model_spec = content.split("\n")[0].strip()[:60]
|
||||
desc = f"chat_with_model: {model_spec}"
|
||||
result = await do_chat_with_model(content, session_id)
|
||||
result = await do_chat_with_model(content, session_id, owner=owner)
|
||||
|
||||
elif tool == "create_session":
|
||||
name = content.split("\n")[0].strip()[:60]
|
||||
@@ -1779,7 +1790,7 @@ async def dispatch_ai_tool(
|
||||
|
||||
elif tool == "pipeline":
|
||||
desc = "pipeline: running steps"
|
||||
result = await do_pipeline(content, session_id)
|
||||
result = await do_pipeline(content, session_id, owner=owner)
|
||||
|
||||
elif tool == "manage_session":
|
||||
action = content.split("\n")[0].strip()[:40]
|
||||
@@ -1794,17 +1805,17 @@ async def dispatch_ai_tool(
|
||||
elif tool == "list_models":
|
||||
keyword = content.strip()[:40]
|
||||
desc = f"list_models{': ' + keyword if keyword else ''}"
|
||||
result = await do_list_models(content, session_id)
|
||||
result = await do_list_models(content, session_id, owner=owner)
|
||||
|
||||
elif tool == "ui_control":
|
||||
action = content.split("\n")[0].strip()[:60]
|
||||
desc = f"ui_control: {action}"
|
||||
result = await do_ui_control(content, session_id)
|
||||
result = await do_ui_control(content, session_id, owner=owner)
|
||||
|
||||
elif tool == "ask_teacher":
|
||||
problem = content.split("\n", 1)[-1].strip()[:60]
|
||||
desc = f"ask_teacher: {problem}"
|
||||
result = await do_ask_teacher(content, session_id)
|
||||
result = await do_ask_teacher(content, session_id, owner=owner)
|
||||
|
||||
else:
|
||||
desc = f"unknown ai tool: {tool}"
|
||||
|
||||
@@ -760,201 +760,6 @@ async def action_extract_email_events(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
return str(e), False
|
||||
|
||||
|
||||
async def action_mark_email_boundaries(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""LLM-based signature / quoted-reply boundary detection. For each new
|
||||
inbox email that we haven't analyzed yet, ask the model to return char
|
||||
offsets where the signature and quoted-reply start. Cache the offsets
|
||||
keyed by Message-ID — once cached, the renderer uses them directly with
|
||||
no further LLM calls. Caps at 30 emails per pass to keep cost bounded.
|
||||
"""
|
||||
try:
|
||||
import sqlite3 as _sql3
|
||||
import json as _json
|
||||
import re as _re
|
||||
import email as _email_mod
|
||||
import asyncio as _aio
|
||||
from datetime import datetime as _dt
|
||||
from routes.email_helpers import _imap_connect, _decode_header, SCHEDULED_DB
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
# Pull recent inbox UIDs + Message-IDs directly via IMAP (the
|
||||
# nested helpers in email_routes aren't importable, and this keeps
|
||||
# the action self-contained).
|
||||
def _pull_recent():
|
||||
results = []
|
||||
conn = _imap_connect(None)
|
||||
try:
|
||||
conn.select("INBOX", readonly=True)
|
||||
status, data = conn.search(None, "ALL")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return results
|
||||
uids = data[0].split()[-50:][::-1] # newest 50
|
||||
for uid in uids:
|
||||
try:
|
||||
st, msg_data = conn.fetch(uid, "(RFC822.HEADER)")
|
||||
if st != "OK" or not msg_data or not msg_data[0]:
|
||||
continue
|
||||
raw = msg_data[0][1] if isinstance(msg_data[0], tuple) else None
|
||||
if not raw:
|
||||
continue
|
||||
msg = _email_mod.message_from_bytes(raw)
|
||||
results.append({
|
||||
"uid": uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
"message_id": (msg.get("Message-ID") or "").strip(),
|
||||
"subject": _decode_header(msg.get("Subject", "")),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
return results
|
||||
|
||||
mails = await _aio.to_thread(_pull_recent)
|
||||
if not mails:
|
||||
raise TaskNoop("no emails to analyze")
|
||||
|
||||
url, model, headers = resolve_endpoint("utility")
|
||||
if not url or not model:
|
||||
url, model, headers = resolve_endpoint("default")
|
||||
if not url or not model:
|
||||
return "No LLM endpoint available", False
|
||||
|
||||
c = _sql3.connect(SCHEDULED_DB)
|
||||
already = {r[0] for r in c.execute(
|
||||
"SELECT message_id FROM email_boundaries"
|
||||
).fetchall()}
|
||||
c.close()
|
||||
|
||||
analyzed = 0
|
||||
skipped = 0
|
||||
for em in mails[:30]:
|
||||
mid = (em.get("message_id") or "").strip()
|
||||
if not mid or mid in already:
|
||||
skipped += 1
|
||||
continue
|
||||
uid = em.get("uid")
|
||||
if not uid:
|
||||
continue
|
||||
def _fetch_body(_uid):
|
||||
conn = _imap_connect(None)
|
||||
try:
|
||||
conn.select("INBOX", readonly=True)
|
||||
st, data = conn.fetch(_uid, "(BODY.PEEK[TEXT])")
|
||||
if st != "OK" or not data or not data[0]:
|
||||
return ""
|
||||
raw = data[0][1] if isinstance(data[0], tuple) else None
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return str(raw)
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
try:
|
||||
body = (await _aio.to_thread(_fetch_body, str(uid))).strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"boundary detection: IMAP fetch failed for uid={uid} mid={mid}: {e}")
|
||||
continue
|
||||
if not body or len(body) < 100:
|
||||
continue
|
||||
# Truncate very long bodies — boundaries usually live in the
|
||||
# first few KB of plain text.
|
||||
truncated = body[:8000]
|
||||
|
||||
prompt = (
|
||||
"Identify where the signature and the quoted-reply start in "
|
||||
"this email body. Return ONLY raw JSON, no prose. Schema:\n"
|
||||
'{"sig_start": <int>, "quote_start": <int>}\n\n'
|
||||
"Rules:\n"
|
||||
"- sig_start = char offset where the sender's signature block "
|
||||
"begins (closing phrase like 'Best regards' / 'Mit freundlichen' / "
|
||||
"'Med vänliga' / contact details / disclaimer / job title block). "
|
||||
"Use -1 if none.\n"
|
||||
"- quote_start = char offset where any quoted-reply / forwarded "
|
||||
"thread begins (lines like 'On <date>, <name> wrote:', "
|
||||
"'From: ... Sent: ... Subject:' in any language — German 'Von:', "
|
||||
"French 'De :', Spanish 'De:', etc.). Use -1 if none.\n"
|
||||
"- Both offsets are byte/char positions in the input string starting "
|
||||
"from 0. The signature/quote should INCLUDE the marker line itself.\n"
|
||||
"- If both exist, sig_start is normally before quote_start (sig of "
|
||||
"the current message, then quoted thread underneath).\n\n"
|
||||
f"BODY (length={len(truncated)}):\n{truncated}"
|
||||
)
|
||||
try:
|
||||
raw = await llm_call_async(
|
||||
url=url, model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0, max_tokens=200,
|
||||
headers=headers, timeout=60,
|
||||
)
|
||||
from src.text_helpers import strip_think as _st
|
||||
raw = _st(raw or "", prose=False, prompt_echo=False)
|
||||
raw = _re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=_re.MULTILINE).strip()
|
||||
# Balanced-brace match: handles {"sig_start": 10, "info": {}}
|
||||
# which the previous [^{}] class would have broken on.
|
||||
start = raw.find("{")
|
||||
m_text = None
|
||||
if start != -1:
|
||||
depth = 0
|
||||
for i in range(start, len(raw)):
|
||||
if raw[i] == "{":
|
||||
depth += 1
|
||||
elif raw[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
m_text = raw[start:i + 1]
|
||||
break
|
||||
if not m_text:
|
||||
logger.warning(f"boundary detection: no JSON object in LLM response for mid={mid}: {raw[:200]!r}")
|
||||
continue
|
||||
parsed = _json.loads(m_text)
|
||||
sig = int(parsed.get("sig_start", -1))
|
||||
quote = int(parsed.get("quote_start", -1))
|
||||
except Exception as e:
|
||||
logger.warning(f"boundary detection failed for mid={mid}: {e}")
|
||||
continue
|
||||
|
||||
# Also pre-parse the thread tree so the client never has to.
|
||||
try:
|
||||
from src.email_thread_parser import parse_thread, THREAD_PARSER_VERSION
|
||||
# The boundary loop only has the plaintext body; parse_thread
|
||||
# also accepts None for HTML so this is safe.
|
||||
turns = parse_thread(None, body)
|
||||
turns_json = (
|
||||
_json.dumps({"v": THREAD_PARSER_VERSION, "turns": turns})
|
||||
if turns else None
|
||||
)
|
||||
except Exception as _pe:
|
||||
logger.debug(f"thread parse failed for {mid}: {_pe}")
|
||||
turns_json = None
|
||||
|
||||
try:
|
||||
c = _sql3.connect(SCHEDULED_DB)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO email_boundaries "
|
||||
"(message_id, uid, folder, sig_start, quote_start, model_used, created_at, turns_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(mid, str(uid), "INBOX", sig, quote, model, _dt.utcnow().isoformat(), turns_json),
|
||||
)
|
||||
c.commit()
|
||||
c.close()
|
||||
analyzed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"could not cache boundaries for {mid}: {e}")
|
||||
|
||||
if analyzed == 0:
|
||||
# All recent emails already had boundaries cached — nothing new
|
||||
# to do, don't pollute Activity.
|
||||
raise TaskNoop(f"boundaries already cached for {skipped} email(s)")
|
||||
return f"Marked boundaries: {analyzed} new, {skipped} cached", True
|
||||
except Exception as e:
|
||||
logger.error(f"mark_email_boundaries failed: {e}")
|
||||
return str(e), False
|
||||
|
||||
|
||||
# Sender local-parts (matched exactly or by prefix) whose mail never carries a
|
||||
# personal signature worth learning. These compare against the local-part
|
||||
@@ -2205,7 +2010,6 @@ BUILTIN_ACTIONS = {
|
||||
# ping_events removed from the user-facing registry. Calendar reminders
|
||||
# are represented as Notes, so note pings are the single dispatch path.
|
||||
"daily_brief": action_daily_brief,
|
||||
"mark_email_boundaries": action_mark_email_boundaries,
|
||||
"learn_sender_signatures": action_learn_sender_signatures,
|
||||
"ssh_command": action_ssh_command,
|
||||
"run_script": action_run_script,
|
||||
@@ -2227,7 +2031,6 @@ BUILTIN_ACTION_INFO = {
|
||||
"extract_email_events": "Scan emails for booking/meeting confirmations and auto-add to calendar",
|
||||
"classify_events": "Tag upcoming events with importance (low/normal/high/critical) and type (work/health/travel/etc.); colors them too",
|
||||
"daily_brief": "Build a morning digest: today's calendar, unread email count + top senders, active todos",
|
||||
"mark_email_boundaries": "LLM-detect signature & quoted-reply offsets in new emails; cached so future renders fold without further LLM calls",
|
||||
"learn_sender_signatures": "LLM learns each sender's signature from 3+ of their recent emails; cached per address so future renders fold sigs reliably without heuristics",
|
||||
"ssh_command": "Run a shell command on a local or remote host",
|
||||
"run_script": "Run a script locally or on ODYSSEUS_SCRIPT_HOST",
|
||||
|
||||
+13
-1
@@ -39,7 +39,17 @@ _PRIVATE_NETWORKS = (
|
||||
|
||||
|
||||
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
|
||||
return any(addr in net for net in _PRIVATE_NETWORKS) or addr.is_private or addr.is_loopback
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return (
|
||||
addr.is_private
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_reserved
|
||||
or addr.is_multicast
|
||||
or addr.is_unspecified
|
||||
or any(addr in net for net in _PRIVATE_NETWORKS)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hostname_ips(hostname: str) -> List[ipaddress._BaseAddress]:
|
||||
@@ -57,6 +67,8 @@ def _public_http_url(url: str) -> bool:
|
||||
host = parsed.hostname.strip().lower()
|
||||
if host in ("localhost", "metadata.google.internal", "metadata"):
|
||||
return False
|
||||
if host.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
|
||||
return False
|
||||
try:
|
||||
return not _is_private_address(ipaddress.ip_address(host))
|
||||
except ValueError:
|
||||
|
||||
+23
-4
@@ -211,7 +211,6 @@ HOUSEKEEPING_DEFAULTS = {
|
||||
"draft_email_replies": {"name": "Email AI Auto Reply", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Tidy Email (Replies)", "AI Auto Reply"]},
|
||||
"extract_email_events": {"name": "Email Calendar Events", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */1 * * *", "ship_paused": True, "legacy_names": ["Email → Calendar Events"]},
|
||||
"classify_events": {"name": "Calendar Classify Events", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 6,18 * * *", "ship_paused": True, "legacy_names": ["Classify Calendar Events"]},
|
||||
"mark_email_boundaries": {"name": "Email Mark Boundaries", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 */2 * * *", "ship_paused": True, "legacy_names": ["Mark Email Boundaries"]},
|
||||
"check_email_urgency": {"name": "Email Tags", "schedule": "cron", "scheduled_time": None, "cron_expression": "0 * * * *", "ship_paused": True, "old_cron_expressions": ["*/15 * * * *"], "legacy_names": ["Email Triage", "Urgent Email"]},
|
||||
"audit_skills": {"name": "Skills Audit", "trigger_type": "event", "trigger_event": "skill_added", "trigger_count": 5, "schedule": None, "scheduled_time": None, "cron_expression": None, "legacy_names": ["Audit Skills"]},
|
||||
}
|
||||
@@ -219,6 +218,7 @@ HOUSEKEEPING_DEFAULTS = {
|
||||
RETIRED_HOUSEKEEPING_ACTIONS = frozenset({
|
||||
"tidy_calendar",
|
||||
"tidy_email_inbox",
|
||||
"mark_email_boundaries",
|
||||
})
|
||||
|
||||
|
||||
@@ -944,7 +944,6 @@ class TaskScheduler:
|
||||
# Activity log + reminder email already carry everything the user needs.
|
||||
_SILENT_ACTIONS = frozenset({
|
||||
"check_email_urgency",
|
||||
"mark_email_boundaries",
|
||||
"learn_sender_signatures",
|
||||
"summarize_emails",
|
||||
"draft_email_replies",
|
||||
@@ -963,7 +962,6 @@ class TaskScheduler:
|
||||
"draft_email_replies",
|
||||
"extract_email_events",
|
||||
"classify_events",
|
||||
"mark_email_boundaries",
|
||||
"learn_sender_signatures",
|
||||
"check_email_urgency",
|
||||
"test_skills",
|
||||
@@ -1946,11 +1944,30 @@ class TaskScheduler:
|
||||
task.task_type = "action"
|
||||
task.action = action
|
||||
|
||||
from core.database import TaskRun
|
||||
retired_ids = [
|
||||
row[0] for row in db.query(ScheduledTask.id).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
ScheduledTask.task_type == "action",
|
||||
ScheduledTask.action.in_(list(RETIRED_HOUSEKEEPING_ACTIONS)),
|
||||
).all()
|
||||
]
|
||||
if retired_ids:
|
||||
db.query(TaskRun).filter(TaskRun.task_id.in_(retired_ids)).delete(synchronize_session=False)
|
||||
retired_count = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
ScheduledTask.task_type == "action",
|
||||
ScheduledTask.action.in_(list(RETIRED_HOUSEKEEPING_ACTIONS)),
|
||||
).delete(synchronize_session=False)
|
||||
# Sweep orphan TaskRun rows (parent task deleted previously) so
|
||||
# retired actions stop showing in Activity. Only runs when at least
|
||||
# one live task exists — avoids wiping run history on a fresh DB.
|
||||
try:
|
||||
live_ids = {row[0] for row in db.query(ScheduledTask.id).all()}
|
||||
if live_ids:
|
||||
db.query(TaskRun).filter(~TaskRun.task_id.in_(list(live_ids))).delete(synchronize_session=False)
|
||||
except Exception:
|
||||
pass
|
||||
existing_actions = {
|
||||
row[0] for row in db.query(ScheduledTask.action).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
@@ -2088,11 +2105,13 @@ class TaskScheduler:
|
||||
db.add(task)
|
||||
seeded.append(action)
|
||||
if seeded or renamed or removed_dupes or retired_count:
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s",
|
||||
owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count,
|
||||
)
|
||||
# Always commit — the orphan-run sweep above may have produced
|
||||
# pending deletes even when no defaults changed.
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create default tasks: {e}")
|
||||
finally:
|
||||
|
||||
+1
-1
@@ -399,7 +399,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"action_name": {"type": "string", "enum": [
|
||||
"tidy_sessions", "tidy_documents", "consolidate_memory", "tidy_research",
|
||||
"summarize_emails", "draft_email_replies", "extract_email_events",
|
||||
"classify_events", "mark_email_boundaries", "learn_sender_signatures",
|
||||
"classify_events", "learn_sender_signatures",
|
||||
"test_skills", "audit_skills", "check_email_urgency"
|
||||
],
|
||||
"description": "Built-in action (for task_type=action)"},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Small helpers for route-local upload size caps."""
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
|
||||
def format_byte_limit(limit: int) -> str:
|
||||
if limit % (1024 * 1024) == 0:
|
||||
return f"{limit // (1024 * 1024)} MB"
|
||||
if limit % 1024 == 0:
|
||||
return f"{limit // 1024} KB"
|
||||
return f"{limit} bytes"
|
||||
|
||||
|
||||
async def read_upload_limited(upload: UploadFile, limit: int, label: str = "Upload") -> bytes:
|
||||
"""Read an UploadFile with a hard byte cap."""
|
||||
data = await upload.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"{label} exceeds {format_byte_limit(limit)} limit",
|
||||
)
|
||||
return data
|
||||
+4
-4
@@ -1886,7 +1886,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Email Accounts</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div class="admin-toggle-sub" style="margin:0;flex:1;">Add, edit, delete, and test accounts in Integrations.</div>
|
||||
<button class="admin-btn-add" id="set-email-open-integrations">Manage in Integrations</button>
|
||||
<button class="admin-btn-add" id="set-email-open-integrations" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Manage in Integrations</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Email Tasks</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div class="admin-toggle-sub" style="margin:0;flex:1;">Manage email background tasks in Tasks.</div>
|
||||
<button class="admin-btn-add" id="set-email-open-tasks">Open Tasks</button>
|
||||
<button class="admin-btn-add" id="set-email-open-tasks" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Open Tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2115,12 +2115,12 @@
|
||||
<!-- ═══ INTEGRATIONS TAB ═══ -->
|
||||
<div data-settings-panel="integrations" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Connections</h2>
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Integrations</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">All external service connections in one place.</div>
|
||||
<div id="unified-integrations-list"></div>
|
||||
<div id="unified-intg-form" style="display:none"></div>
|
||||
<div style="text-align:center;padding:8px 0;">
|
||||
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn">+ Add Integration</button>
|
||||
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn" style="display:inline-flex;align-items:center;gap:6px;">+ Add Integration<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2456,6 +2456,26 @@ async function _reconnectTask(el, task) {
|
||||
const isNetwork = /connection|timeout|timed out|incompleteread|chunkedencoding|reset by peer|protocolerror|all connection attempts failed/i.test(lastOutput);
|
||||
const progressMatch = String(lastOutput || '').match(/(\d+)%\|/);
|
||||
const nearDone = progressMatch && Number(progressMatch[1]) >= 80;
|
||||
// Reconnect: most "crashed" downloads near the end are actually
|
||||
// finished — we just missed the DOWNLOAD_OK / /snapshots/ marker
|
||||
// because output rolled over, or the tmux session ended a tick
|
||||
// before we polled. Probing has-session and re-attaching to
|
||||
// capture-pane lets the existing _reconnectTask flow pick up
|
||||
// the real state (running, finished, or truly dead).
|
||||
const _reconnectFix = {
|
||||
label: 'Reconnect',
|
||||
action: () => {
|
||||
_updateTask(task.sessionId, { status: 'running' });
|
||||
el.dataset.status = 'running';
|
||||
const badge2 = el.querySelector('.cookbook-task-status');
|
||||
if (badge2) { badge2.textContent = _statusLabel('running', task.type); badge2.className = 'cookbook-task-status'; }
|
||||
const _diagEl = el.querySelector('.cookbook-diagnosis');
|
||||
if (_diagEl) _diagEl.remove();
|
||||
const _wave = el.querySelector('.cookbook-task-wave'); if (_wave) _wave.style.display = '';
|
||||
const _up = el.querySelector('.cookbook-task-uptime'); if (_up) _up.style.display = '';
|
||||
_reconnectTask(el, task);
|
||||
},
|
||||
};
|
||||
const diag = {
|
||||
message: isDisk
|
||||
? 'Download stopped because this server ran out of disk space.'
|
||||
@@ -2467,28 +2487,88 @@ async function _reconnectTask(el, task) {
|
||||
suggestion: isDisk
|
||||
? 'Suggested action: free disk space, then retry the download. HuggingFace resumes incomplete files when possible.'
|
||||
: nearDone
|
||||
? 'Suggested action: retry the download. It may briefly look like it restarted while cached files are checked, then it should reuse incomplete files.'
|
||||
: 'Suggested action: retry the download. HuggingFace resumes incomplete files when possible.',
|
||||
fixes: [
|
||||
{ label: 'Retry download', action: () => _retryTask(el, task) },
|
||||
{ label: 'Copy last 50 lines', action: () => {
|
||||
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
|
||||
_copyText(last || 'No download log available.');
|
||||
} },
|
||||
],
|
||||
? 'Suggested action: hit Reconnect first — the download may have finished after the output buffer rolled over. Retry only if reconnect cannot recover.'
|
||||
: 'Suggested action: hit Reconnect to re-attach to the tmux session. If that fails, retry — HuggingFace resumes incomplete files when possible.',
|
||||
fixes: isDisk
|
||||
? [
|
||||
{ label: 'Retry download', action: () => _retryTask(el, task) },
|
||||
{ label: 'Copy last 50 lines', action: () => {
|
||||
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
|
||||
_copyText(last || 'No download log available.');
|
||||
} },
|
||||
]
|
||||
: [
|
||||
_reconnectFix,
|
||||
{ label: 'Retry download', action: () => _retryTask(el, task) },
|
||||
{ label: 'Copy last 50 lines', action: () => {
|
||||
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
|
||||
_copyText(last || 'No download log available.');
|
||||
} },
|
||||
],
|
||||
};
|
||||
_showDiagnosis(el, diag, lastOutput);
|
||||
// Auto-probe: if the tmux session is still alive (download
|
||||
// genuinely still in progress), _selfHealStaleTasks flips the
|
||||
// task back to running and the diagnosis disappears without
|
||||
// the user needing to click Reconnect.
|
||||
if (nearDone) setTimeout(() => { _selfHealStaleTasks().catch(() => {}); }, 1200);
|
||||
}
|
||||
_showCookbookNotif(true);
|
||||
} else {
|
||||
_updateTask(task.sessionId, { status: 'done' });
|
||||
el.dataset.status = 'done';
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = _statusLabel('done', task.type); badge.className = 'cookbook-task-status cookbook-task-done'; }
|
||||
const _chk = el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = '';
|
||||
const _sb = el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = '';
|
||||
_showCookbookNotif();
|
||||
_refreshDepsAfterInstall(task);
|
||||
// Debounce the done flip. Tmux capture-pane can fail transiently
|
||||
// (network blip, ssh reconnect), and the verify has-session right
|
||||
// above can briefly report dead even when the session is in the
|
||||
// middle of finalizing. Marking done immediately + the periodic
|
||||
// _selfHealStaleTasks then flipping back to running causes the
|
||||
// status badge to oscillate between Finished and Downloading.
|
||||
// Wait 30s and re-probe: only finalize as done if tmux is STILL
|
||||
// gone. If the session resurfaces, restart _reconnectTask so live
|
||||
// capture resumes without the user seeing a fake "done" first.
|
||||
if (!task._doneConfirmAt) {
|
||||
_updateTask(task.sessionId, { _doneConfirmAt: Date.now() + 30000 });
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const fresh = _loadTasks().find(t => t.sessionId === task.sessionId);
|
||||
if (!fresh) return;
|
||||
let stillAlive = false;
|
||||
try {
|
||||
const probe = await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`), timeout: 5 }),
|
||||
});
|
||||
const pData = await probe.json();
|
||||
stillAlive = pData.exit_code === 0;
|
||||
} catch { /* network blip — treat as inconclusive, prefer running */ stillAlive = true; }
|
||||
if (stillAlive) {
|
||||
_updateTask(task.sessionId, { status: 'running', _doneConfirmAt: null });
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
|
||||
if (_el) {
|
||||
_el.dataset.status = 'running';
|
||||
const _badge = _el.querySelector('.cookbook-task-status');
|
||||
if (_badge) { _badge.textContent = _statusLabel('running', task.type); _badge.className = 'cookbook-task-status'; }
|
||||
const _wave = _el.querySelector('.cookbook-task-wave'); if (_wave) _wave.style.display = '';
|
||||
const _up = _el.querySelector('.cookbook-task-uptime'); if (_up) _up.style.display = '';
|
||||
_reconnectTask(_el, _loadTasks().find(t => t.sessionId === task.sessionId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_updateTask(task.sessionId, { status: 'done', _doneConfirmAt: null });
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
|
||||
if (_el) {
|
||||
_el.dataset.status = 'done';
|
||||
const _badge = _el.querySelector('.cookbook-task-status');
|
||||
if (_badge) { _badge.textContent = _statusLabel('done', task.type); _badge.className = 'cookbook-task-status cookbook-task-done'; }
|
||||
const _chk = _el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = '';
|
||||
const _sb = _el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = '';
|
||||
}
|
||||
_showCookbookNotif();
|
||||
_refreshDepsAfterInstall(task);
|
||||
_renderRunningTab();
|
||||
_processQueue();
|
||||
} catch { /* swallow — next polling cycle will retry */ }
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
}
|
||||
_renderRunningTab();
|
||||
|
||||
@@ -938,6 +938,7 @@ function _wireChipDrag(chip, dock) {
|
||||
if (tz) {
|
||||
const dx = (tz.left + tz.width / 2) - (l.x + l.width / 2);
|
||||
const dy = (tz.top + tz.height / 2) - (l.y + l.height / 2);
|
||||
l.chip.classList.add('chip-trashing');
|
||||
l.chip.style.transition = 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in, left 0.32s cubic-bezier(0.45, 0, 0.25, 1), top 0.32s cubic-bezier(0.45, 0, 0.25, 1)';
|
||||
// Whirlpool: spin + shrink so the chip swirls into the X.
|
||||
l.chip.style.transform = 'scale(0.15) rotate(720deg)';
|
||||
@@ -1001,6 +1002,7 @@ function _wireChipDrag(chip, dock) {
|
||||
// `!important`, so the close animation needs setProperty(...important)
|
||||
// too or the styles don't apply and the chip just snaps.
|
||||
const cur = chip.style.transform || 'translate(0,0)';
|
||||
chip.classList.add('chip-trashing');
|
||||
chip.style.setProperty('transition', 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in', 'important');
|
||||
// Whirlpool: spin + shrink as the chip swirls into the X.
|
||||
chip.style.setProperty('transform', `${cur} scale(0.15) rotate(720deg)`, 'important');
|
||||
|
||||
@@ -2145,6 +2145,21 @@ function _bindCardEvents(body) {
|
||||
});
|
||||
});
|
||||
}
|
||||
// Mobile, non-select: tapping anywhere on the card body (not on an
|
||||
// interactive child — buttons, pin, checkbox, color dot, reminder pill,
|
||||
// agent tag, links) opens the fullscreen editor. Previously only the
|
||||
// title / content preview triggered edit, so padding + empty gutters were
|
||||
// dead zones that felt broken on mobile.
|
||||
if (_isNotesMobileMode() && !_selectMode) {
|
||||
const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
|
||||
body.querySelectorAll('.note-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest(_INTERACTIVE)) return;
|
||||
e.stopPropagation();
|
||||
tapToEditOrSelect(card);
|
||||
});
|
||||
});
|
||||
}
|
||||
// Multi-select checkbox (only in select mode)
|
||||
body.querySelectorAll('.note-card-cb').forEach(cb => {
|
||||
cb.addEventListener('click', (e) => e.stopPropagation());
|
||||
@@ -3456,6 +3471,14 @@ function _buildForm(note = null) {
|
||||
// let repeated clicks create duplicate notes.
|
||||
const _saveBtn = form.querySelector('.note-form-save');
|
||||
if (_saveBtn._saving) return;
|
||||
// Mobile: when an existing note is opened and closed without edits, the
|
||||
// Update (✓) button morphs into Archive (set up below). Route the click
|
||||
// to the hidden archive button so the existing archive flow + undo toast
|
||||
// run unchanged.
|
||||
if (_saveBtn.classList.contains('archive-mode')) {
|
||||
form.querySelector('.note-form-archive-btn')?.click();
|
||||
return;
|
||||
}
|
||||
_saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
|
||||
try {
|
||||
const title = form.querySelector('.note-form-title').value.trim();
|
||||
@@ -3550,6 +3573,28 @@ function _buildForm(note = null) {
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile-only: when editing an existing note, the Update (✓) button starts in
|
||||
// archive-mode (visually + behaviorally) and flips to Update on the first
|
||||
// edit. Lets the user tap a note to skim, then tap ✓ to archive without ever
|
||||
// touching a separate Archive button.
|
||||
if (isEdit && window.innerWidth <= 768) {
|
||||
const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label');
|
||||
const _enterArchive = () => {
|
||||
_saveBtnEl0.classList.add('archive-mode');
|
||||
if (_saveLabelEl) _saveLabelEl.textContent = 'Archive';
|
||||
_saveBtnEl0.title = 'Archive';
|
||||
};
|
||||
const _enterUpdate = () => {
|
||||
if (!_saveBtnEl0.classList.contains('archive-mode')) return;
|
||||
_saveBtnEl0.classList.remove('archive-mode');
|
||||
if (_saveLabelEl) _saveLabelEl.textContent = 'Update';
|
||||
_saveBtnEl0.title = 'Update';
|
||||
};
|
||||
_enterArchive();
|
||||
form.addEventListener('input', _enterUpdate, true);
|
||||
form.addEventListener('change', _enterUpdate, true);
|
||||
}
|
||||
|
||||
// Cancel
|
||||
form.querySelector('.note-form-cancel').addEventListener('click', () => { _clearDraft(isEdit ? note.id : '__new__'); _editingId = null; _renderNotes(); });
|
||||
|
||||
|
||||
+628
-28
@@ -3093,9 +3093,69 @@ const INTG_TYPES = {
|
||||
carddav: { label: 'CardDAV', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' },
|
||||
email: { label: 'Email', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>' },
|
||||
mcp: { label: 'MCP', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>' },
|
||||
codex: { label: 'Codex', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>' },
|
||||
claude: { label: 'Claude', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>' },
|
||||
vault: { label: 'Vault', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
|
||||
};
|
||||
|
||||
// Config shared by the Codex Agent and Claude Agent forms. Both use the same
|
||||
// scope-gated /api/codex/* backend; this just parameterizes the UI label,
|
||||
// default token name, and the per-agent install commands.
|
||||
const AGENT_CONFIGS = {
|
||||
codex: {
|
||||
label: 'Codex Agent',
|
||||
word: 'Codex',
|
||||
namePrefix: 'codex agent',
|
||||
defaultName: 'Codex Agent',
|
||||
pluginPath: '/api/codex/plugin.zip',
|
||||
setupDescription: 'Downloads the plugin bundle and registers it with Codex. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the plugin from <a href="/api/codex/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>, and runs <code>codex plugin add odysseus@personal</code>.',
|
||||
buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin}
|
||||
export ODYSSEUS_API_TOKEN='${token}'
|
||||
mkdir -p ~/plugins
|
||||
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
|
||||
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
p = Path.home() / ".agents" / "plugins" / "marketplace.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text())
|
||||
else:
|
||||
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
|
||||
|
||||
data.setdefault("name", "personal")
|
||||
data.setdefault("interface", {}).setdefault("displayName", "Personal")
|
||||
plugins = data.setdefault("plugins", [])
|
||||
entry = {
|
||||
"name": "odysseus",
|
||||
"source": {"source": "local", "path": "./plugins/odysseus"},
|
||||
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
|
||||
"category": "Productivity",
|
||||
}
|
||||
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
|
||||
p.write_text(json.dumps(data, indent=2) + "\\n")
|
||||
PY
|
||||
codex plugin add odysseus@personal
|
||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`,
|
||||
},
|
||||
claude: {
|
||||
label: 'Claude Agent',
|
||||
word: 'Claude',
|
||||
namePrefix: 'claude agent',
|
||||
defaultName: 'Claude Agent',
|
||||
pluginPath: '/api/claude/plugin.zip',
|
||||
setupDescription: 'Downloads the skill bundle into <code>~/.claude/skills/odysseus/</code>. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the skill from <a href="/api/claude/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>. Claude Code auto-loads the skill on next start.',
|
||||
buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin}
|
||||
export ODYSSEUS_API_TOKEN='${token}'
|
||||
mkdir -p ~/.claude
|
||||
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip
|
||||
python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities`,
|
||||
},
|
||||
};
|
||||
|
||||
let _unifiedInited = false;
|
||||
|
||||
async function initUnifiedIntegrations() {
|
||||
@@ -3113,7 +3173,7 @@ async function initUnifiedIntegrations() {
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes] = await Promise.all([
|
||||
const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes, tokenRes] = await Promise.all([
|
||||
fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })),
|
||||
fetch('/api/calendar/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
||||
fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
||||
@@ -3121,6 +3181,7 @@ async function initUnifiedIntegrations() {
|
||||
fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
|
||||
fetch('/api/mcp/servers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
|
||||
fetch('/api/vault/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
||||
fetch('/api/tokens', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
|
||||
]);
|
||||
const items = [];
|
||||
// API integrations
|
||||
@@ -3165,6 +3226,20 @@ async function initUnifiedIntegrations() {
|
||||
const statusText = srv.needs_oauth ? 'needs auth' : srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : srv.status === 'error' ? 'error' : 'disconnected';
|
||||
items.push({ type: 'mcp', id: srv.id || srv.name, name: srv.name || 'MCP Server', detail: statusText, enabled: srv.is_enabled !== false, data: srv });
|
||||
}
|
||||
for (const tok of (Array.isArray(tokenRes) ? tokenRes : [])) {
|
||||
const scopes = tok.scopes || [];
|
||||
const lowerName = (tok.name || '').toLowerCase();
|
||||
let agentType = null;
|
||||
if (lowerName.startsWith('claude agent')) agentType = 'claude';
|
||||
else if (lowerName.startsWith('codex agent')) agentType = 'codex';
|
||||
else if (scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'))) {
|
||||
// Legacy / un-prefixed scoped tokens fall back to Codex for backwards compat.
|
||||
agentType = 'codex';
|
||||
}
|
||||
if (!agentType) continue;
|
||||
const detail = `${tok.token_prefix || 'token'}... - ${scopes.join(', ') || 'chat'}`;
|
||||
items.push({ type: agentType, id: tok.id, name: tok.name || (agentType === 'claude' ? 'Claude Agent' : 'Codex Agent'), detail, enabled: true, data: tok });
|
||||
}
|
||||
// Vaultwarden removed as an integration option.
|
||||
return items;
|
||||
}
|
||||
@@ -3175,9 +3250,9 @@ async function initUnifiedIntegrations() {
|
||||
// type gets. (The clickable glow-on-test variant for email was
|
||||
// removed earlier; this matches the API/CalDAV/MCP pattern.)
|
||||
const statusDot = item.enabled
|
||||
? '<span style="width:8px;height:8px;border-radius:50%;background:var(--green,#50fa7b);flex-shrink:0" title="Active"></span>'
|
||||
? '<span style="width:8px;height:8px;border-radius:50%;background:var(--color-success,#50fa7b);flex-shrink:0;--notif-glow:var(--color-success,#50fa7b);animation:cookbook-notif-pulse 2s ease-in-out infinite;" title="Active"></span>'
|
||||
: '<span style="width:8px;height:8px;border-radius:50%;background:var(--fg);opacity:0.3;flex-shrink:0" title="Disabled"></span>';
|
||||
return `<div class="intg-card" data-intg-id="${item.id}" data-intg-type="${item.type}" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:6px;margin-bottom:6px;cursor:pointer" title="Click to edit">
|
||||
return `<div class="intg-card" data-intg-id="${item.id}" data-intg-type="${item.type}" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb, var(--fg) 3%, transparent);margin-bottom:6px;cursor:pointer;transition:all 0.15s;" title="Click to edit">
|
||||
<span style="opacity:0.6;flex-shrink:0">${t.icon}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px">${item.name} <span style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:1px 5px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 50%, transparent);border-radius:3px;color:var(--accent, var(--red));background:color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);">${t.label}</span></div>
|
||||
@@ -3236,6 +3311,7 @@ async function initUnifiedIntegrations() {
|
||||
}
|
||||
else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
else if (type === 'codex' || type === 'claude') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
} catch (_) {}
|
||||
formEl.style.display = 'none';
|
||||
@@ -3252,6 +3328,8 @@ async function initUnifiedIntegrations() {
|
||||
else if (type === 'contacts' || type === 'carddav') showCardDavForm();
|
||||
else if (type === 'email') showEmailForm(editId);
|
||||
else if (type === 'mcp') showMcpForm(editId);
|
||||
else if (type === 'codex') showAgentForm('codex', editId);
|
||||
else if (type === 'claude') showAgentForm('claude', editId);
|
||||
else if (type === 'vault') showVaultForm();
|
||||
}
|
||||
|
||||
@@ -3276,15 +3354,46 @@ async function initUnifiedIntegrations() {
|
||||
// and they're patchy on mobile browsers. A native select renders
|
||||
// the same everywhere and makes the available options visible
|
||||
// without needing the user to type.
|
||||
const selectOpts = presetEntries
|
||||
.sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0]))
|
||||
const sortedPresets = presetEntries.sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0]));
|
||||
const selectOpts = sortedPresets
|
||||
.map(([k, p]) => `<option value="${k}">${esc(p.name || k)}</option>`)
|
||||
.join('');
|
||||
// Letter-in-brand-color logo for each API preset; outline plug icon for
|
||||
// "Custom (no preset)". Matches the email-provider dropdown pattern.
|
||||
const _apiLetter = (letter, bg) => `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="11" fill="${bg}"/><text x="12" y="16.5" font-size="13" font-weight="700" text-anchor="middle" fill="#fff" font-family="system-ui,sans-serif">${letter}</text></svg>`;
|
||||
const _apiCustomIco = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
const API_PRESET_LOGO = {
|
||||
miniflux: _apiLetter('M', '#214c87'),
|
||||
gitea: _apiLetter('G', '#609926'),
|
||||
linkding: _apiLetter('L', '#1f2937'),
|
||||
home_assistant: _apiLetter('H', '#41bdf5'),
|
||||
ntfy: _apiLetter('n', '#317f43'),
|
||||
vaultwarden: _apiLetter('V', '#175ddc'),
|
||||
freshrss: _apiLetter('R', '#ef6c00'),
|
||||
};
|
||||
const _apiIconFor = (k) => {
|
||||
if (!k) return _apiCustomIco;
|
||||
if (API_PRESET_LOGO[k]) return API_PRESET_LOGO[k];
|
||||
const first = (presets[k]?.name || k).trim().charAt(0).toUpperCase() || '?';
|
||||
return _apiLetter(first, '#6b7280');
|
||||
};
|
||||
const _apiRows = [['', 'Custom (no preset)'], ...sortedPresets.map(([k, p]) => [k, p.name || k])]
|
||||
.map(([k, label]) => `<button type="button" class="ufapi-option" data-value="${esc(k)}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;">${_apiIconFor(k)}<span>${esc(label)}</span></button>`).join('');
|
||||
formEl.innerHTML = `
|
||||
<div class="admin-card" style="margin-top:8px">
|
||||
<h2 style="font-size:13px">${editId ? 'Edit' : 'Add'} API Integration</h2>
|
||||
<h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" 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;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>API Integration</h2>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row"><label class="settings-label">Preset</label><select id="uf-api-preset" class="settings-select"><option value="">Custom (no preset)</option>${selectOpts}</select></div>
|
||||
<div class="settings-row"><label class="settings-label">Preset</label>
|
||||
<div style="position:relative;flex:1;min-width:0;">
|
||||
<select id="uf-api-preset" tabindex="-1" aria-hidden="true" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"><option value="">Custom (no preset)</option>${selectOpts}</select>
|
||||
<button type="button" id="uf-api-preset-trigger" class="settings-select" style="display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
|
||||
<span class="ufapi-icon" style="display:inline-flex;align-items:center;">${_apiCustomIco}</span>
|
||||
<span class="ufapi-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom (no preset)</span>
|
||||
<span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
|
||||
</button>
|
||||
<div id="uf-api-preset-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_apiRows}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-api-name" class="settings-input" placeholder="My Service"></div>
|
||||
<div class="settings-row"><label class="settings-label">Base URL</label><input id="uf-api-url" class="settings-input" placeholder="http://localhost:8080"></div>
|
||||
<div id="uf-api-ntfy-hint" style="display:none;font-size:11px;line-height:1.35;opacity:0.68;margin:-2px 0 2px 106px;"></div>
|
||||
@@ -3294,6 +3403,48 @@ async function initUnifiedIntegrations() {
|
||||
<div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-api-save">Save</button><button class="admin-btn-sm" id="uf-api-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-api-cancel" style="opacity:0.7">Cancel</button><span id="uf-api-msg" style="font-size:11px"></span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
// Custom preset dropdown wire-up (hidden select stays as data source).
|
||||
(() => {
|
||||
const trig = el('uf-api-preset-trigger');
|
||||
const menu = el('uf-api-preset-menu');
|
||||
const sel = el('uf-api-preset');
|
||||
if (!trig || !menu || !sel) return;
|
||||
const lbl = trig.querySelector('.ufapi-label');
|
||||
const ico = trig.querySelector('.ufapi-icon');
|
||||
const _setFromKey = (k) => {
|
||||
const row = menu.querySelector(`.ufapi-option[data-value="${k}"]`);
|
||||
const text = row?.querySelector('span')?.textContent || 'Custom (no preset)';
|
||||
if (lbl) lbl.textContent = text;
|
||||
if (ico) ico.innerHTML = _apiIconFor(k);
|
||||
};
|
||||
const _close = () => { menu.style.display = 'none'; };
|
||||
const _open = () => {
|
||||
menu.style.display = 'block';
|
||||
const tRect = trig.getBoundingClientRect();
|
||||
const mRect = menu.getBoundingClientRect();
|
||||
const below = window.innerHeight - tRect.bottom;
|
||||
const above = tRect.top;
|
||||
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
|
||||
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
};
|
||||
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
|
||||
menu.querySelectorAll('.ufapi-option').forEach(btn => {
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const k = btn.dataset.value || '';
|
||||
sel.value = k;
|
||||
_setFromKey(k);
|
||||
_close();
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
_setFromKey(sel.value || '');
|
||||
})();
|
||||
|
||||
const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key'), ntfyHint = el('uf-api-ntfy-hint');
|
||||
let _editId = editId && editId !== 'new' ? editId : null;
|
||||
// Load existing
|
||||
@@ -3451,17 +3602,27 @@ async function initUnifiedIntegrations() {
|
||||
async function showCardDavForm() {
|
||||
formEl.innerHTML = `
|
||||
<div class="admin-card" style="margin-top:8px">
|
||||
<h2 style="font-size:13px">Contacts (CardDAV)</h2>
|
||||
<h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" 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;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Contacts (CardDAV)</h2>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row"><label class="settings-label">URL</label><input id="uf-carddav-url" class="settings-input" placeholder="http://localhost:5232/user/contacts/"></div>
|
||||
<div class="settings-row"><label class="settings-label">Username</label><input id="uf-carddav-user" class="settings-input"></div>
|
||||
<div class="settings-row"><label class="settings-label">Password</label><input id="uf-carddav-pass" class="settings-input" type="password"></div>
|
||||
<div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-carddav-save">Save</button><button class="admin-btn-sm" id="uf-carddav-cancel" style="opacity:0.7">Cancel</button><span id="uf-carddav-msg" style="font-size:11px"></span></div>
|
||||
<div class="settings-row" style="margin-top:8px;align-items:center;">
|
||||
<button class="admin-btn-add" id="uf-carddav-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>
|
||||
Save
|
||||
</button>
|
||||
<span id="uf-carddav-msg" style="font-size:11px;flex:1;margin-left:8px"></span>
|
||||
<button class="admin-btn-add" id="uf-carddav-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card contacts-manager" style="margin-top:8px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<h2 style="font-size:13px;margin:0;">Contacts Import <span id="cm-count" style="opacity:0.5;font-weight:normal;font-size:11px;"></span></h2>
|
||||
<h2 style="font-size:13px;margin:0;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" 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;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Contacts Import <span id="cm-count" style="opacity:0.5;font-weight:normal;font-size:11px;"></span></h2>
|
||||
<button class="admin-btn-sm" id="cm-import-btn" style="margin-left:auto;">Import</button>
|
||||
<button class="admin-btn-sm" id="cm-export-vcf-btn">Export .vcf</button>
|
||||
<button class="admin-btn-sm" id="cm-export-csv-btn">Export .csv</button>
|
||||
@@ -3621,8 +3782,14 @@ async function initUnifiedIntegrations() {
|
||||
<div class="contact-name" style="font-size:12px;font-weight:600;">${esc(c.name || '(no name)')}</div>
|
||||
<div class="contact-sub" style="font-size:10px;opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(sub)}</div>
|
||||
</div>
|
||||
<button class="admin-btn-sm contact-edit" title="Edit">Edit</button>
|
||||
<button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.75;">Delete</button>
|
||||
<button class="admin-btn-sm contact-edit" title="Edit" style="display:inline-flex;align-items:center;gap:4px;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 35%, var(--border));">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Edit
|
||||
</button>
|
||||
<button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.85;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div class="contact-row-edit" style="display:none;flex-direction:column;gap:4px;">
|
||||
<input class="settings-input contact-edit-name" value="${esc(c.name || '')}" placeholder="Name">
|
||||
@@ -3704,22 +3871,49 @@ async function initUnifiedIntegrations() {
|
||||
};
|
||||
const _providerOptions = Object.entries(PROVIDERS)
|
||||
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`).join('');
|
||||
// Provider logos — small SVGs the custom dropdown renders next to each
|
||||
// option. Letter-in-brand-color circle for known providers; outline
|
||||
// envelope for "Custom…". Inline SVG (no external assets, no emoji).
|
||||
const _letterLogo = (letter, bg) => `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="11" fill="${bg}"/><text x="12" y="16.5" font-size="13" font-weight="700" text-anchor="middle" fill="#fff" font-family="system-ui,sans-serif">${letter}</text></svg>`;
|
||||
const _customLogo = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;opacity:0.7"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>';
|
||||
const PROV_LOGO = {
|
||||
'': _customLogo,
|
||||
gmail: _letterLogo('G', '#ea4335'),
|
||||
migadu: _letterLogo('M', '#3aa39d'),
|
||||
icloud: _letterLogo('i', '#3693f3'),
|
||||
outlook: _letterLogo('O', '#0078d4'),
|
||||
fastmail: _letterLogo('F', '#4a5fbb'),
|
||||
yahoo: _letterLogo('Y', '#6001d2'),
|
||||
dovecot: _letterLogo('D', '#6b7280'),
|
||||
};
|
||||
const _provOptionRows = [['', 'Custom…'], ...Object.entries(PROVIDERS).map(([k, v]) => [k, v.label])]
|
||||
.map(([k, label]) => `<button type="button" class="ufp-option" data-value="${esc(k)}" style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;">${PROV_LOGO[k] || _customLogo}<span>${esc(label)}</span></button>`).join('');
|
||||
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
|
||||
formEl.innerHTML = `
|
||||
<div class="admin-card" style="margin-top:8px">
|
||||
<h2 style="font-size:13px">${isEdit ? 'Edit' : 'Add'} Email Account</h2>
|
||||
<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="uf-email-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>
|
||||
<div class="ufp-wrap" style="position:relative;flex:1;min-width:0;">
|
||||
<select id="uf-email-provider" tabindex="-1" aria-hidden="true" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"><option value="">Custom…</option>${_providerOptions}</select>
|
||||
<button type="button" id="uf-email-provider-trigger" class="settings-select" style="display:flex;align-items:center;gap:8px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
|
||||
<span class="ufp-icon" style="display:inline-flex;align-items:center;">${PROV_LOGO['']}</span>
|
||||
<span class="ufp-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom…</span>
|
||||
<span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
|
||||
</button>
|
||||
<div id="uf-email-provider-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:280px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_provOptionRows}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="uf-email-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="uf-email-name" class="settings-input" placeholder="(optional — leave blank to use email)"></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="uf-email-from" class="settings-input" placeholder="you@example.com"></div>
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 0 2px">IMAP (Receiving)</div>
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 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"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>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="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">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">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">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">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="uf-smtp-port" class="settings-input" type="number" placeholder="465" style="max-width:100px"></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>
|
||||
@@ -3836,6 +4030,56 @@ async function initUnifiedIntegrations() {
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// Custom dropdown wire-up — the native <select> stays in the DOM as the
|
||||
// data source and accessibility target, but the visible UI is a button +
|
||||
// popup so each provider row can render with its SVG logo. Selecting an
|
||||
// option updates select.value and dispatches a `change` event so the
|
||||
// existing autofill handler below runs unchanged.
|
||||
(() => {
|
||||
const trigger = el('uf-email-provider-trigger');
|
||||
const menu = el('uf-email-provider-menu');
|
||||
const sel = el('uf-email-provider');
|
||||
if (!trigger || !menu || !sel) return;
|
||||
const labelEl = trigger.querySelector('.ufp-label');
|
||||
const iconEl = trigger.querySelector('.ufp-icon');
|
||||
const _setFromKey = (k) => {
|
||||
const row = menu.querySelector(`.ufp-option[data-value="${k}"]`);
|
||||
const lbl = row?.querySelector('span')?.textContent || 'Custom…';
|
||||
if (labelEl) labelEl.textContent = lbl;
|
||||
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
|
||||
};
|
||||
const _closeMenu = () => { menu.style.display = 'none'; };
|
||||
const _openMenu = () => {
|
||||
menu.style.display = 'block';
|
||||
// Drop-up when there's not enough room below the trigger.
|
||||
const tRect = trigger.getBoundingClientRect();
|
||||
const mRect = menu.getBoundingClientRect();
|
||||
const below = window.innerHeight - tRect.bottom;
|
||||
const above = tRect.top;
|
||||
if (mRect.height > below && above > below) {
|
||||
menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)';
|
||||
} else {
|
||||
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
|
||||
}
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
};
|
||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
||||
menu.querySelectorAll('.ufp-option').forEach(btn => {
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const k = btn.dataset.value || '';
|
||||
sel.value = k;
|
||||
_setFromKey(k);
|
||||
_closeMenu();
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
_setFromKey(sel.value || '');
|
||||
})();
|
||||
|
||||
// Provider preset → autofill IMAP + SMTP host/port + STARTTLS, set the
|
||||
// helper note, and update the Email/Username placeholders to a
|
||||
// provider-specific example so users see the right format at a glance.
|
||||
@@ -4302,30 +4546,386 @@ async function initUnifiedIntegrations() {
|
||||
}
|
||||
}
|
||||
|
||||
async function showAgentForm(kind, editId) {
|
||||
const cfg = AGENT_CONFIGS[kind] || AGENT_CONFIGS.codex;
|
||||
let tokens = [];
|
||||
try {
|
||||
const tokRes = await fetch('/api/tokens', { credentials: 'same-origin' });
|
||||
if (tokRes.ok) tokens = await tokRes.json();
|
||||
} catch (_) {}
|
||||
|
||||
const toolScopes = [
|
||||
{ key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
|
||||
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
|
||||
{ key: 'documents:read', label: 'Documents', detail: 'Read documents when a document API is enabled' },
|
||||
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
|
||||
{ key: 'email:read', label: 'Email', detail: 'Read email when an email API is enabled' },
|
||||
{ key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts without sending' },
|
||||
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
|
||||
{ key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events when enabled' },
|
||||
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
|
||||
{ key: 'memory:read', label: 'Memory', detail: 'Read memory when enabled' },
|
||||
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
|
||||
];
|
||||
// Strict name-prefix match keeps Codex and Claude tokens in their own forms.
|
||||
const agentTokens = (Array.isArray(tokens) ? tokens : []).filter(tok =>
|
||||
(tok.name || '').toLowerCase().startsWith(cfg.namePrefix)
|
||||
);
|
||||
const current = agentTokens.find(t => String(t.id) === String(editId));
|
||||
const _scopeIcons = {
|
||||
todos: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="8" y1="9" x2="16" y2="9"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>',
|
||||
documents: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
|
||||
email: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg>',
|
||||
calendar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
|
||||
memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0-2.5 2.5A2.5 2.5 0 0 0 2 9.5v3A2.5 2.5 0 0 0 4.5 15a2.5 2.5 0 0 0 2.5 2.5A2.5 2.5 0 0 0 9.5 20H10V2z"/><path d="M14.5 2a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1 2.5 2.5A2.5 2.5 0 0 1 22 9.5v3A2.5 2.5 0 0 1 19.5 15a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 14.5 20H14V2z"/></svg>',
|
||||
};
|
||||
const _scopeNiceLabel = (label) => label.replace(/\s+(write|drafts?|send)$/i, '');
|
||||
const _scopeAction = (key) => (key.split(':')[1] || '').toLowerCase();
|
||||
const _pillStyle = (action) => {
|
||||
if (action === 'read') return 'background:rgba(150,150,150,0.18);color:var(--fg-muted,#888);';
|
||||
return 'background:color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);color:var(--accent, var(--red));';
|
||||
};
|
||||
const scopeToggles = (t) => {
|
||||
const scopes = new Set(t.scopes || []);
|
||||
return toolScopes.map(scope => {
|
||||
const tool = scope.key.split(':')[0];
|
||||
const action = _scopeAction(scope.key);
|
||||
const icon = _scopeIcons[tool] || '';
|
||||
const niceLabel = _scopeNiceLabel(scope.label);
|
||||
return `
|
||||
<label class="settings-row" style="align-items:center;gap:8px;display:flex;min-height:30px;padding:2px 0;">
|
||||
<span style="opacity:0.7;display:inline-flex;align-items:center;justify-content:center;width:16px;flex-shrink:0;">${icon}</span>
|
||||
<span class="settings-label" style="width:75px;flex-shrink:0;padding:0;">${esc(niceLabel)}</span>
|
||||
<span style="font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:1px 7px;border-radius:999px;flex-shrink:0;min-width:44px;text-align:center;margin-left:-3px;box-sizing:border-box;${_pillStyle(action)}">${esc(action)}</span>
|
||||
<span style="font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;">${esc(scope.detail)}</span>
|
||||
<label class="admin-switch" style="margin-left:auto;flex-shrink:0;"><input type="checkbox" class="uf-codex-scope" data-token-id="${esc(t.id)}" data-scope="${esc(scope.key)}" ${scopes.has(scope.key) ? 'checked' : ''}><span class="admin-slider"></span></label>
|
||||
</label>`;
|
||||
}).join('');
|
||||
};
|
||||
const tokenRows = agentTokens.length ? agentTokens.map(t => `
|
||||
<div class="uf-codex-token" data-token-id="${esc(t.id)}" style="border:1px solid var(--border);border-radius:6px;padding:9px 10px;margin-top:8px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<input type="text" class="uf-codex-rename settings-input" data-token-id="${esc(t.id)}" value="${esc(t.name || cfg.defaultName)}" placeholder="${esc(cfg.defaultName)} (e.g. ${esc(cfg.word)} on laptop)" style="font-size:12px;font-weight:600;padding:3px 6px;width:100%;background:transparent;border:1px solid transparent;border-radius:4px;" title="Click to rename this agent">
|
||||
<div style="font-size:10px;opacity:0.52;margin-top:2px;">${esc(t.token_prefix || 'token')}...${t.last_used_at ? ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}` : ' · Never used'}</div>
|
||||
</div>
|
||||
<button class="admin-btn-sm uf-codex-copy-prefix" data-token-prefix="${esc(t.token_prefix || '')}" title="Copy token prefix (full token only shown once, at creation)" style="opacity:0.7">Copy</button>
|
||||
<button class="admin-btn-delete uf-codex-revoke" data-token-id="${esc(t.id)}">Revoke</button>
|
||||
</div>
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.62;margin-bottom:4px;">Tool access</div>
|
||||
${scopeToggles(t)}
|
||||
<div class="uf-codex-scope-msg" data-token-id="${esc(t.id)}" style="font-size:11px;min-height:14px;"></div>
|
||||
</div>`).join('') : `<div style="opacity:0.45;font-size:11px;padding:8px 0;">No ${esc(cfg.word)} tokens yet.</div>`;
|
||||
const origin = window.location.origin || '';
|
||||
const setupForToken = (token) => cfg.buildSetup(origin, token);
|
||||
|
||||
formEl.innerHTML = `
|
||||
<div class="admin-card" style="margin-top:8px">
|
||||
<h2 style="font-size:13px">${esc(cfg.label)}</h2>
|
||||
<div style="font-size:11px;opacity:0.65;line-height:1.45;margin:-2px 0 8px;">Generates a scoped token + setup commands so ${esc(cfg.word)} on your own machine can read/write your Odysseus data (todos, email, calendar, etc.). The agent runs in your terminal — it isn't streamed inside Odysseus.</div>
|
||||
<div class="settings-col">
|
||||
<div id="uf-codex-pending" style="display:${current ? 'none' : 'block'};font-size:11px;opacity:0.6;padding:6px 0;">Creating agent...</div>
|
||||
<div id="uf-codex-reveal" style="display:none;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent, var(--red));border-radius:6px;background:rgba(0,0,0,0.04);width:100%;box-sizing:border-box;">
|
||||
<div style="font-weight:600;font-size:12px;margin-bottom:6px;">${esc(cfg.word)} setup</div>
|
||||
|
||||
<div style="font-size:11px;opacity:0.62;margin-bottom:4px;">Copy this token now — it will not be shown again.</div>
|
||||
<code id="uf-codex-token" style="display:block;word-break:break-all;font-size:11px;padding:6px 8px;background:rgba(0,0,0,0.08);border-radius:4px;"></code>
|
||||
<div style="margin-top:6px;">
|
||||
<button class="admin-btn-sm" id="uf-codex-copy-token">Copy token</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Quickstart — or copy setup directly in your terminal</div>
|
||||
<div style="font-size:11px;opacity:0.62;margin-bottom:6px;">${cfg.setupDescription}</div>
|
||||
<pre style="margin:0;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;font-size:10px;line-height:1.45;padding:8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;width:100%;box-sizing:border-box;"><code id="uf-codex-setup-code"></code></pre>
|
||||
<div style="margin-top:6px;">
|
||||
<button class="admin-btn-sm" id="uf-codex-copy-setup">Copy setup</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Configure access</div>
|
||||
<div style="font-size:11px;opacity:0.62;margin-bottom:6px;">Toggle which Odysseus tools this agent can use. New agents start with chat only.</div>
|
||||
<div id="uf-codex-inline-scopes"></div>
|
||||
</div>
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.62;margin-top:10px;">${agentTokens.length ? 'Existing agents' : 'Agents'}</div>
|
||||
<div id="uf-codex-token-list">${tokenRows}</div>
|
||||
<div class="settings-row" style="margin-top:10px;align-items:center;">
|
||||
<button class="admin-btn-add" id="uf-codex-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>
|
||||
Save
|
||||
</button>
|
||||
<span id="uf-codex-msg" style="font-size:11px;flex:1;margin-left:8px"></span>
|
||||
<button class="admin-btn-add" id="uf-codex-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
el('uf-codex-cancel')?.addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
el('uf-codex-save')?.addEventListener('click', () => {
|
||||
const msg = el('uf-codex-msg');
|
||||
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
|
||||
setTimeout(() => { formEl.style.display = 'none'; }, 350);
|
||||
});
|
||||
|
||||
const _autoCreateCodex = async () => {
|
||||
const msg = el('uf-codex-msg');
|
||||
const pending = el('uf-codex-pending');
|
||||
const existingNames = new Set(agentTokens.map(t => (t.name || '').trim()));
|
||||
let name = cfg.defaultName;
|
||||
let n = 2;
|
||||
while (existingNames.has(name)) { name = `${cfg.defaultName} ${n++}`; }
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('scopes', 'chat');
|
||||
try {
|
||||
const r = await fetch('/api/tokens', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || 'Failed');
|
||||
if (pending) pending.style.display = 'none';
|
||||
el('uf-codex-token').textContent = d.token || '';
|
||||
el('uf-codex-reveal').style.display = '';
|
||||
const setupBtn = el('uf-codex-copy-setup');
|
||||
if (setupBtn) setupBtn.dataset.token = d.token || '';
|
||||
const setupCode = el('uf-codex-setup-code');
|
||||
if (setupCode) setupCode.textContent = setupForToken(d.token || '');
|
||||
// Populate inline scope toggles for the just-created token (Configure access already open)
|
||||
const newToken = { id: d.id, name, scopes: d.scopes || ['chat'] };
|
||||
const inlineEl = el('uf-codex-inline-scopes');
|
||||
if (inlineEl) {
|
||||
inlineEl.innerHTML = `
|
||||
<div class="uf-codex-token" data-token-id="${esc(newToken.id)}">
|
||||
${scopeToggles(newToken)}
|
||||
<div class="uf-codex-scope-msg" data-token-id="${esc(newToken.id)}" style="font-size:11px;min-height:14px;"></div>
|
||||
</div>`;
|
||||
_wireScopeChange(inlineEl);
|
||||
}
|
||||
if (msg) {
|
||||
msg.textContent = `Created "${name}".`;
|
||||
msg.style.color = 'var(--green, #50fa7b)';
|
||||
}
|
||||
await renderList();
|
||||
} catch (err) {
|
||||
if (pending) pending.style.display = 'none';
|
||||
if (msg) {
|
||||
msg.textContent = err?.message || 'Failed';
|
||||
msg.style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!current) _autoCreateCodex();
|
||||
const _copyCodexToken = async (text) => {
|
||||
const value = String(text || '');
|
||||
if (!value) return false;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch (_) {}
|
||||
}
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.setAttribute('readonly', 'readonly');
|
||||
ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, value.length);
|
||||
let ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
|
||||
ta.remove();
|
||||
return ok;
|
||||
};
|
||||
const _selectTextFallback = (text, containerId) => {
|
||||
const code = document.createElement('pre');
|
||||
code.textContent = text;
|
||||
code.style.cssText = 'white-space:pre-wrap;word-break:break-word;font-size:10px;margin:6px 0 0;';
|
||||
el(containerId)?.appendChild(code);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(code);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
};
|
||||
el('uf-codex-copy-setup')?.addEventListener('click', async () => {
|
||||
const token = el('uf-codex-copy-setup')?.dataset.token || '';
|
||||
const btn = el('uf-codex-copy-setup');
|
||||
if (!token) {
|
||||
if (btn) {
|
||||
btn.textContent = 'Add agent first';
|
||||
setTimeout(() => { const latest = el('uf-codex-copy-setup'); if (latest) latest.textContent = 'Copy setup'; }, 1600);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const setup = setupForToken(token);
|
||||
const ok = await _copyCodexToken(setup);
|
||||
if (!btn) return;
|
||||
btn.textContent = ok ? 'Copied setup' : 'Select setup';
|
||||
if (!ok) _selectTextFallback(setup, 'uf-codex-reveal');
|
||||
setTimeout(() => { const latest = el('uf-codex-copy-setup'); if (latest) latest.textContent = 'Copy setup'; }, 1600);
|
||||
});
|
||||
el('uf-codex-copy-token')?.addEventListener('click', async () => {
|
||||
const token = el('uf-codex-token')?.textContent || '';
|
||||
const ok = await _copyCodexToken(token);
|
||||
const btn = el('uf-codex-copy-token');
|
||||
if (!btn) return;
|
||||
btn.textContent = ok ? 'Copied token' : 'Select token';
|
||||
if (!ok) _selectTextFallback(token, 'uf-codex-reveal');
|
||||
setTimeout(() => { const latest = el('uf-codex-copy-token'); if (latest) latest.textContent = 'Copy token'; }, 1600);
|
||||
});
|
||||
formEl.querySelectorAll('.uf-codex-revoke').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!await window.styledConfirm(`Revoke this ${cfg.word} token? Terminal agents using it will lose access.`, { confirmText: 'Revoke', danger: true })) return;
|
||||
await fetch(`/api/tokens/${btn.dataset.tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
formEl.style.display = 'none';
|
||||
await renderList();
|
||||
});
|
||||
});
|
||||
// Rename: PATCH the token's name when the user blurs the input (or hits Enter).
|
||||
formEl.querySelectorAll('.uf-codex-rename').forEach(input => {
|
||||
const original = input.value;
|
||||
const commit = async () => {
|
||||
const name = (input.value || '').trim();
|
||||
if (!name || name === original) return;
|
||||
try {
|
||||
const r = await fetch(`/api/tokens/${input.dataset.tokenId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!r.ok) throw new Error('Save failed');
|
||||
input.style.borderColor = 'var(--green, #50fa7b)';
|
||||
setTimeout(() => { input.style.borderColor = 'transparent'; }, 800);
|
||||
await renderList();
|
||||
} catch (_) {
|
||||
input.value = original;
|
||||
input.style.borderColor = 'var(--red)';
|
||||
setTimeout(() => { input.style.borderColor = 'transparent'; }, 1200);
|
||||
}
|
||||
};
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
|
||||
});
|
||||
// Copy token prefix (full token irrecoverable after the one-time creation reveal).
|
||||
formEl.querySelectorAll('.uf-codex-copy-prefix').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const prefix = btn.dataset.tokenPrefix || '';
|
||||
if (!prefix) return;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(prefix);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = prefix;
|
||||
ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (_) {}
|
||||
ta.remove();
|
||||
}
|
||||
const label = btn.textContent;
|
||||
btn.textContent = 'Copied prefix';
|
||||
setTimeout(() => { btn.textContent = label; }, 1400);
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
function _wireScopeChange(scope) {
|
||||
scope.querySelectorAll('.uf-codex-scope').forEach(cb => {
|
||||
if (cb.dataset.wired === '1') return;
|
||||
cb.dataset.wired = '1';
|
||||
cb.addEventListener('change', async () => {
|
||||
const tokenId = cb.dataset.tokenId;
|
||||
const panel = formEl.querySelector(`.uf-codex-token[data-token-id="${CSS.escape(tokenId)}"]`);
|
||||
const msg = formEl.querySelector(`.uf-codex-scope-msg[data-token-id="${CSS.escape(tokenId)}"]`);
|
||||
const scopes = Array.from(panel.querySelectorAll('.uf-codex-scope:checked')).map(input => input.dataset.scope);
|
||||
try {
|
||||
const r = await fetch(`/api/tokens/${tokenId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scopes }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(d.detail || 'Failed');
|
||||
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
|
||||
await renderList();
|
||||
} catch (err) {
|
||||
cb.checked = !cb.checked;
|
||||
if (msg) { msg.textContent = err?.message || 'Failed'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
_wireScopeChange(formEl);
|
||||
}
|
||||
|
||||
// ── Add button with type picker ──
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', () => {
|
||||
formEl.style.display = '';
|
||||
const _typeOptions = [
|
||||
['api', 'API Service'],
|
||||
['caldav', 'CalDAV Calendar'],
|
||||
['claude', 'Claude Agent'],
|
||||
['codex', 'Codex Agent'],
|
||||
['carddav', 'Contacts (CardDAV)'],
|
||||
['contacts', 'Contacts Import'],
|
||||
['email', 'Email (IMAP/SMTP)'],
|
||||
['mcp', 'MCP Tool Server'],
|
||||
];
|
||||
const _iconFor = (k) => (INTG_TYPES[k]?.icon || '').replace(/width="14"/, 'width="16"').replace(/height="14"/, 'height="16"');
|
||||
const _rowsHtml = _typeOptions.map(([k, label]) => `<button type="button" class="uf-type-option" data-value="${k}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;"><span style="display:inline-flex;color:var(--accent, var(--red));flex-shrink:0;">${_iconFor(k)}</span><span>${esc(label)}</span></button>`).join('');
|
||||
formEl.innerHTML = `
|
||||
<div class="admin-card" style="margin-top:8px">
|
||||
<h2 style="font-size:13px">Add Integration</h2>
|
||||
<h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" 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;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Add Integration</h2>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row"><label class="settings-label">Type</label>
|
||||
<select id="uf-type-picker" class="settings-input">
|
||||
<option value="">Select...</option>
|
||||
<option value="api">API Service</option>
|
||||
<option value="caldav">CalDAV Calendar</option>
|
||||
<option value="contacts">Contacts Import</option>
|
||||
<option value="carddav">Contacts (CardDAV)</option>
|
||||
<option value="email">Email (IMAP/SMTP)</option>
|
||||
<option value="mcp">MCP Tool Server</option>
|
||||
</select>
|
||||
<div style="position:relative;flex:1;min-width:0;">
|
||||
<button type="button" id="uf-type-trigger" class="settings-select" style="display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
|
||||
<span class="uf-type-icon" style="display:inline-flex;color:var(--accent, var(--red));"></span>
|
||||
<span class="uf-type-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:0.65;">Select...</span>
|
||||
<span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
|
||||
</button>
|
||||
<div id="uf-type-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_rowsHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
el('uf-type-picker').addEventListener('change', () => {
|
||||
const v = el('uf-type-picker').value;
|
||||
if (v) showForm(v, 'new');
|
||||
const trigger = el('uf-type-trigger');
|
||||
const menu = el('uf-type-menu');
|
||||
const labelEl = trigger.querySelector('.uf-type-label');
|
||||
const iconEl = trigger.querySelector('.uf-type-icon');
|
||||
const _closeMenu = () => { menu.style.display = 'none'; };
|
||||
const _openMenu = () => {
|
||||
menu.style.display = 'block';
|
||||
// Drop-up when there's not enough room below the trigger (mobile
|
||||
// landscape / docked keyboard / long lists near the bottom of screen).
|
||||
const tRect = trigger.getBoundingClientRect();
|
||||
const mRect = menu.getBoundingClientRect();
|
||||
const below = window.innerHeight - tRect.bottom;
|
||||
const above = tRect.top;
|
||||
if (mRect.height > below && above > below) {
|
||||
menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)';
|
||||
} else {
|
||||
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
|
||||
}
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
};
|
||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
||||
menu.querySelectorAll('.uf-type-option').forEach(btn => {
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const k = btn.dataset.value;
|
||||
const lbl = btn.querySelector('span:last-child')?.textContent || '';
|
||||
if (labelEl) { labelEl.textContent = lbl; labelEl.style.opacity = '1'; }
|
||||
if (iconEl) iconEl.innerHTML = _iconFor(k);
|
||||
_closeMenu();
|
||||
showForm(k, 'new');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+1
-5
@@ -327,7 +327,6 @@ const _TASK_ICONS = {
|
||||
draft_email_replies: '<polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>',
|
||||
extract_email_events:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="M7 14h5"/><path d="M7 18h8"/>',
|
||||
classify_events: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="M8 15h.01M12 15h.01M16 15h.01"/>',
|
||||
mark_email_boundaries:'<path d="M4 4h16v16H4z"/><path d="M4 9h16"/><path d="M9 4v16"/>',
|
||||
learn_sender_signatures:'<path d="M20 6 9 17l-5-5"/><path d="M14 6h6v6"/>',
|
||||
check_email_urgency: '<path d="M13.73 21a2 2 0 0 1-3.46 0"/><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/>',
|
||||
// Skills
|
||||
@@ -355,7 +354,6 @@ const _MODEL_BACKED_ACTIONS = new Set([
|
||||
'draft_email_replies',
|
||||
'extract_email_events',
|
||||
'classify_events',
|
||||
'mark_email_boundaries',
|
||||
'learn_sender_signatures',
|
||||
'check_email_urgency',
|
||||
'test_skills',
|
||||
@@ -498,7 +496,6 @@ const _CATEGORY_MAP = {
|
||||
extract_email_events: 'Calendar',
|
||||
summarize_emails: 'Email',
|
||||
draft_email_replies: 'Email',
|
||||
mark_email_boundaries: 'Email',
|
||||
learn_sender_signatures: 'Email',
|
||||
check_email_urgency: 'Email',
|
||||
daily_brief: 'Assistant',
|
||||
@@ -609,7 +606,6 @@ const _TASK_CACHE_LABELS = {
|
||||
summarize_emails: 'email summaries',
|
||||
draft_email_replies: 'AI reply drafts',
|
||||
extract_email_events: 'email calendar cache',
|
||||
mark_email_boundaries: 'email boundaries',
|
||||
learn_sender_signatures: 'sender signatures',
|
||||
check_email_urgency: 'email tags',
|
||||
};
|
||||
@@ -1739,7 +1735,7 @@ async function _renderActivityView() {
|
||||
<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;">Activity</h2>
|
||||
<button class="memory-toolbar-btn" id="tasks-activity-refresh" title="Refresh" style="margin-left:auto;">Refresh</button>
|
||||
<button class="memory-toolbar-btn" id="tasks-activity-refresh" title="Refresh" style="margin-left:auto;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
</div>
|
||||
<p class="memory-desc">Recent task runs across all scheduled tasks.</p>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin:6px 0 8px;">
|
||||
|
||||
+63
-3
@@ -1008,6 +1008,16 @@ body.bg-pattern-sparkles {
|
||||
opacity: 1;
|
||||
animation: whirlpool-burst 0.36s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
/* When a chip is swirling into the trash X, its inline `rotate(720deg)`
|
||||
drags every child + ::after badge along with it — the count/dot pill
|
||||
spinning looks chaotic. Fade those out fast at the start of the close
|
||||
so visually only the icon glyph rotates. */
|
||||
.minimized-dock-chip.chip-trashing > :not(svg),
|
||||
.minimized-dock-chip.chip-trashing::after,
|
||||
.minimized-dock-chip.chip-trashing::before {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.16s ease-out !important;
|
||||
}
|
||||
@keyframes whirlpool-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes whirlpool-burst {
|
||||
0% { transform: rotate(0deg) scale(1); opacity: 1; }
|
||||
@@ -10326,7 +10336,7 @@ textarea.memory-add-input {
|
||||
}
|
||||
.task-state-badge svg {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
top: 0;
|
||||
}
|
||||
.task-status-badge:hover {
|
||||
filter: brightness(1.08) saturate(1.15);
|
||||
@@ -13290,6 +13300,13 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* When the integrations editor opens, the inner admin-card should match the
|
||||
listed integration cards (subtle tint, same border) instead of reverting
|
||||
to the solid-panel admin-card surface used elsewhere. */
|
||||
#unified-intg-form .admin-card,
|
||||
#integrations-form .admin-card {
|
||||
background: color-mix(in srgb, var(--fg) 3%, transparent);
|
||||
}
|
||||
.admin-card h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -21544,9 +21561,11 @@ body:not(.welcome-ready) #welcome-screen {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
.task-log-force-run svg {
|
||||
.task-log-force-run svg,
|
||||
.task-log-stop svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.task-log-force-run:hover {
|
||||
opacity: 1;
|
||||
@@ -21918,13 +21937,14 @@ a.chat-link[href^="#research-"] {
|
||||
padding-right: 0;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
top: 2px !important;
|
||||
}
|
||||
.task-card .task-state-badge .task-state-label {
|
||||
display: none;
|
||||
}
|
||||
.task-card .task-card-run-btn {
|
||||
margin-right: 1px !important;
|
||||
top: 0;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27291,6 +27311,20 @@ button .spinner-whirlpool {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
/* Mobile: long recipient lists (To/Cc with many addresses) shouldn't wrap to
|
||||
N rows and push the body down. Keep them on one row, horizontally scrollable,
|
||||
no scrollbar chrome. */
|
||||
@media (max-width: 768px) {
|
||||
.recipient-chips {
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.recipient-chips::-webkit-scrollbar { display: none; }
|
||||
.recipient-chip { flex-shrink: 0; }
|
||||
}
|
||||
.email-reader-actions {
|
||||
display: flex; gap: 4px; flex-wrap: nowrap; align-items: center;
|
||||
flex-shrink: 0;
|
||||
@@ -30837,6 +30871,20 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
|
||||
margin-top: -2px;
|
||||
}
|
||||
/* Reminder bell button */
|
||||
/* Mobile-only: bell icon in the note editor is accent-coloured so it pops as
|
||||
the primary "set a reminder" affordance. The Archive button is hidden — the
|
||||
Update (✓) button morphs into an Archive action when the user opens a note
|
||||
and clicks without making any edits (see notes.js `archive-mode` toggle). */
|
||||
@media (max-width: 768px) {
|
||||
.note-form-remind-btn { color: var(--accent, var(--red)) !important; }
|
||||
.note-form-remind-btn > svg { color: var(--accent, var(--red)); }
|
||||
.note-form-archive-btn { display: none !important; }
|
||||
.note-form-save.archive-mode {
|
||||
color: var(--accent, var(--red)) !important;
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 50%, transparent) !important;
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent) !important;
|
||||
}
|
||||
}
|
||||
.note-form-remind-btn {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
@@ -33174,6 +33222,18 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
|
||||
.email-attach-toggle-inline,
|
||||
.email-undone-toggle-inline,
|
||||
.email-reminder-toggle-inline { border-radius: 50% !important; opacity: 1 !important; }
|
||||
/* Mobile: enlarge the icons inside the inline search-bar toggles
|
||||
(done / attachment / reminders) — buttons themselves stay the same,
|
||||
only the SVG glyph scales up so it's tappable + visible. */
|
||||
@media (max-width: 768px) {
|
||||
.email-attach-toggle-inline svg,
|
||||
.email-undone-toggle-inline svg,
|
||||
.email-reminder-toggle-inline svg,
|
||||
.email-filter-refresh-btn svg {
|
||||
width: 15px !important;
|
||||
height: 15px !important;
|
||||
}
|
||||
}
|
||||
.email-attach-toggle:not(.email-attach-toggle-inline):hover svg {
|
||||
animation: email-undone-jiggle 0.45s ease-in-out;
|
||||
transform-origin: 50% 50%;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from src import ai_interaction
|
||||
|
||||
|
||||
def _source(fn) -> str:
|
||||
return inspect.getsource(fn)
|
||||
|
||||
|
||||
def test_model_resolver_applies_owner_filter():
|
||||
body = _source(ai_interaction._resolve_model)
|
||||
|
||||
assert "owner: Optional[str] = None" in body
|
||||
assert "from src.auth_helpers import owner_filter" in body
|
||||
assert "owner_filter(query, ModelEndpoint, owner)" in body
|
||||
|
||||
|
||||
def test_model_listing_and_image_fallback_are_owner_scoped():
|
||||
list_body = _source(ai_interaction.do_list_models)
|
||||
image_body = _source(ai_interaction.do_generate_image)
|
||||
|
||||
assert "owner: Optional[str] = None" in list_body
|
||||
assert "owner_filter(query, ModelEndpoint, owner)" in list_body
|
||||
assert "_resolve_model(candidate, owner=owner)" in image_body
|
||||
assert "owner_filter(_img_q, ModelEndpoint, owner)" in image_body
|
||||
assert "_resolve_model(model_spec, owner=owner)" in image_body
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool,content", [
|
||||
("chat_with_model", "gpt-test\nhello"),
|
||||
("pipeline", "gpt-test | summarize this"),
|
||||
("list_models", ""),
|
||||
("ui_control", "switch_model gpt-test"),
|
||||
("ask_teacher", "gpt-test\nhelp me"),
|
||||
])
|
||||
async def test_dispatch_passes_owner_to_model_tools(monkeypatch, tool, content):
|
||||
seen = {}
|
||||
|
||||
async def capture(name, content, session_id=None, owner=None):
|
||||
seen[name] = {"content": content, "session_id": session_id, "owner": owner}
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
ai_interaction,
|
||||
"do_chat_with_model",
|
||||
lambda content, session_id=None, owner=None: capture("chat_with_model", content, session_id, owner),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ai_interaction,
|
||||
"do_pipeline",
|
||||
lambda content, session_id=None, owner=None: capture("pipeline", content, session_id, owner),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ai_interaction,
|
||||
"do_list_models",
|
||||
lambda content, session_id=None, owner=None: capture("list_models", content, session_id, owner),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ai_interaction,
|
||||
"do_ui_control",
|
||||
lambda content, session_id=None, owner=None: capture("ui_control", content, session_id, owner),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ai_interaction,
|
||||
"do_ask_teacher",
|
||||
lambda content, session_id=None, owner=None: capture("ask_teacher", content, session_id, owner),
|
||||
)
|
||||
|
||||
_desc, result = await ai_interaction.dispatch_ai_tool(tool, content, session_id="sid1", owner="alice")
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert seen[tool]["owner"] == "alice"
|
||||
assert seen[tool]["session_id"] == "sid1"
|
||||
@@ -6,6 +6,7 @@ calling do_manage_calendar with an rrule stores a single event carrying that RRU
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
@@ -14,6 +15,21 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
|
||||
def _drop_fake_core_database():
|
||||
parent = sys.modules.get("core")
|
||||
attr = getattr(parent, "database", None) if parent is not None else None
|
||||
mod = sys.modules.get("core.database") or attr
|
||||
if mod is None or isinstance(getattr(mod, "__file__", None), str):
|
||||
return
|
||||
sys.modules.pop("core.database", None)
|
||||
sys.modules.pop("src.database", None)
|
||||
if parent is not None and attr is mod:
|
||||
delattr(parent, "database")
|
||||
|
||||
|
||||
_drop_fake_core_database()
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import CalendarEvent
|
||||
|
||||
@@ -32,6 +48,10 @@ def _bind_temp_db(monkeypatch):
|
||||
# do_manage_calendar does `from core.database import SessionLocal` at call
|
||||
# time, so patch the module attribute to our temp DB — via monkeypatch so it
|
||||
# is RESTORED after each test and can't leak into later tests in the process.
|
||||
monkeypatch.setitem(sys.modules, "core.database", cdb)
|
||||
parent = sys.modules.get("core")
|
||||
if parent is not None:
|
||||
monkeypatch.setattr(parent, "database", cdb, raising=False)
|
||||
monkeypatch.setattr(cdb, "SessionLocal", _TS)
|
||||
yield
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
_endpoint_resolver = sys.modules.get("src.endpoint_resolver")
|
||||
if _endpoint_resolver is not None and not getattr(_endpoint_resolver, "__file__", None):
|
||||
sys.modules.pop("src.endpoint_resolver", None)
|
||||
sys.modules.pop("routes.model_routes", None)
|
||||
sys.modules.pop("routes.chat_routes", None)
|
||||
|
||||
from routes import chat_routes
|
||||
|
||||
|
||||
|
||||
@@ -78,7 +78,12 @@ from core.middleware import require_admin # noqa: E402
|
||||
|
||||
# --- token minting: shown once, hashed at rest -----------------------------
|
||||
|
||||
def test_mint_token_returns_raw_once_and_stores_only_a_hash():
|
||||
def test_mint_token_returns_raw_once_and_stores_only_a_hash(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "core.database", _db)
|
||||
parent = sys.modules.get("core")
|
||||
if parent is not None:
|
||||
monkeypatch.setattr(parent, "database", _db, raising=False)
|
||||
|
||||
token_id, raw = P.mint_token("alice")
|
||||
assert raw.startswith("ody_")
|
||||
# The persisted row stores a bcrypt hash + prefix, never the plaintext.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Owner-scope regression for /api/compare/start endpoint-key resolution.
|
||||
|
||||
start_comparison() takes caller-supplied endpoint URLs (endpoint_a/endpoint_b),
|
||||
matches a ModelEndpoint by base_url, and copies that row's *decrypted* api_key
|
||||
into the caller-owned [CMP] session's headers — which then drive that session's
|
||||
/api/chat_stream calls. The match must be owner-scoped (the caller's own rows +
|
||||
legacy null-owner shared rows) so a user can't mint a comparison bound to
|
||||
ANOTHER user's private endpoint and spend their api_key / reach their base_url.
|
||||
Mirrors the session `_owned_endpoint` and research `_owned_enabled_endpoint`
|
||||
fixes.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Stub core.database so importing routes.compare_routes (which drags in
|
||||
# core.session_manager) is cheap under the sqlalchemy MagicMock stubs. The
|
||||
# helper resolves ModelEndpoint at call time; we swap in a fake declarative
|
||||
# class below. owner_filter stays REAL.
|
||||
if "core.database" not in sys.modules:
|
||||
sys.modules["core.database"] = types.ModuleType("core.database")
|
||||
_cd = sys.modules["core.database"]
|
||||
_cd.Base = MagicMock()
|
||||
for _name in (
|
||||
"Session", "ChatMessage", "Document", "DocumentVersion", "GalleryImage",
|
||||
"GalleryAlbum", "SessionLocal", "Comparison", "ModelEndpoint",
|
||||
):
|
||||
if not hasattr(_cd, _name):
|
||||
setattr(_cd, _name, MagicMock())
|
||||
|
||||
from routes.compare_routes import _owned_endpoint_by_url # noqa: E402
|
||||
|
||||
|
||||
class _Predicate:
|
||||
def __init__(self, check):
|
||||
self._check = check
|
||||
|
||||
def __call__(self, row):
|
||||
return self._check(row)
|
||||
|
||||
def __or__(self, other):
|
||||
return _Predicate(lambda row: self(row) or other(row))
|
||||
|
||||
|
||||
class _Column:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, value):
|
||||
return _Predicate(lambda row: getattr(row, self.name) == value)
|
||||
|
||||
|
||||
class _ModelEndpoint:
|
||||
base_url = _Column("base_url")
|
||||
owner = _Column("owner")
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *predicates):
|
||||
self._rows = [r for r in self._rows if all(p(r) for p in predicates)]
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def query(self, model):
|
||||
assert model is _ModelEndpoint
|
||||
return _Query(self._rows)
|
||||
|
||||
|
||||
def _ep(base_url, owner):
|
||||
return SimpleNamespace(base_url=base_url, owner=owner, api_key="sk-secret")
|
||||
|
||||
|
||||
def _resolve(rows, base_url, owner):
|
||||
sys.modules["core.database"].ModelEndpoint = _ModelEndpoint
|
||||
return _owned_endpoint_by_url(_DB(rows), base_url, owner)
|
||||
|
||||
|
||||
URL = "https://api.example.com/v1"
|
||||
|
||||
|
||||
def test_rejects_another_owners_private_endpoint():
|
||||
# bob owns the only endpoint at URL; alice supplying that URL gets None
|
||||
# → no headers, no key copied into her comparison session.
|
||||
rows = [_ep(URL, "bob")]
|
||||
assert _resolve(rows, URL, "alice") is None
|
||||
|
||||
|
||||
def test_returns_callers_own_endpoint():
|
||||
rows = [_ep(URL, "bob"), _ep(URL, "alice")]
|
||||
ep = _resolve(rows, URL, "alice")
|
||||
assert ep is not None and ep.owner == "alice"
|
||||
|
||||
|
||||
def test_allows_legacy_null_owner_shared_row():
|
||||
rows = [_ep(URL, None)]
|
||||
ep = _resolve(rows, URL, "alice")
|
||||
assert ep is not None and ep.owner is None
|
||||
|
||||
|
||||
def test_no_match_returns_none():
|
||||
rows = [_ep("https://other.example/v1", "alice")]
|
||||
assert _resolve(rows, URL, "alice") is None
|
||||
|
||||
|
||||
def test_null_owner_is_legacy_single_user_noop():
|
||||
# Single-user / unresolved owner: owner_filter no-op, exact URL match wins.
|
||||
rows = [_ep(URL, "bob")]
|
||||
ep = _resolve(rows, URL, None)
|
||||
assert ep is not None and ep.owner == "bob"
|
||||
@@ -22,7 +22,8 @@ def test_background_status_poll_reconciles_into_local_tasks():
|
||||
assert "const statusById = new Map(tasks.map(t => [t.session_id, t]));" in source
|
||||
assert "const nextStatus = live.status === 'completed'" in source
|
||||
assert "? 'done'" in source
|
||||
assert "live.status === 'error'" in source
|
||||
assert ": (live.status === 'error'" in source
|
||||
assert "? 'error'" in source
|
||||
assert "_saveTasks(localTasks);" in source
|
||||
assert "completedDeps.forEach(t => _refreshDepsAfterInstall(t));" in source
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from src.upload_limits import format_byte_limit, read_upload_limited
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _upload(name: str, data: bytes) -> UploadFile:
|
||||
return UploadFile(filename=name, file=io.BytesIO(data))
|
||||
|
||||
|
||||
def _source(path: str) -> str:
|
||||
return (REPO / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_read_upload_limited_accepts_exact_limit():
|
||||
assert await read_upload_limited(_upload("ok.bin", b"abcd"), 4, "Test upload") == b"abcd"
|
||||
|
||||
|
||||
async def test_read_upload_limited_rejects_oversized_upload():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await read_upload_limited(_upload("too-big.bin", b"abcde"), 4, "Test upload")
|
||||
|
||||
assert exc.value.status_code == 413
|
||||
assert exc.value.detail == "Test upload exceeds 4 bytes limit"
|
||||
|
||||
|
||||
def test_upload_limit_formatting_is_human_readable():
|
||||
assert format_byte_limit(25 * 1024 * 1024) == "25 MB"
|
||||
assert format_byte_limit(512 * 1024) == "512 KB"
|
||||
assert format_byte_limit(7) == "7 bytes"
|
||||
|
||||
|
||||
def test_direct_upload_routes_use_bounded_reads():
|
||||
expectations = {
|
||||
"routes/stt_routes.py": [
|
||||
"read_upload_limited(file, STT_MAX_AUDIO_BYTES",
|
||||
],
|
||||
"routes/gallery_routes.py": [
|
||||
"read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES",
|
||||
"read_upload_limited(file, GALLERY_TRANSFORM_UPLOAD_MAX_BYTES",
|
||||
],
|
||||
"routes/memory_routes.py": [
|
||||
"read_upload_limited(file, MEMORY_IMPORT_MAX_BYTES",
|
||||
],
|
||||
"routes/calendar_routes.py": [
|
||||
"read_upload_limited(file, _ICS_MAX_BYTES",
|
||||
],
|
||||
"routes/email_routes.py": [
|
||||
"read_upload_limited(file, EMAIL_COMPOSE_UPLOAD_MAX_BYTES",
|
||||
],
|
||||
}
|
||||
|
||||
for path, needles in expectations.items():
|
||||
text = _source(path)
|
||||
for needle in needles:
|
||||
assert needle in text
|
||||
@@ -13,6 +13,7 @@ while completing reliably everywhere.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import sys
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -21,6 +22,21 @@ from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _drop_fake_core_database():
|
||||
parent = sys.modules.get("core")
|
||||
attr = getattr(parent, "database", None) if parent is not None else None
|
||||
mod = sys.modules.get("core.database") or attr
|
||||
if mod is None or isinstance(getattr(mod, "__file__", None), str):
|
||||
return
|
||||
sys.modules.pop("core.database", None)
|
||||
sys.modules.pop("src.database", None)
|
||||
if parent is not None and attr is mod:
|
||||
delattr(parent, "database")
|
||||
|
||||
|
||||
_drop_fake_core_database()
|
||||
|
||||
import core.database as cdb
|
||||
import routes.document_routes as droutes
|
||||
from core.database import Document
|
||||
|
||||
@@ -141,4 +141,3 @@ def test_build_anthropic_payload_alternating_roles():
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Memory routes must owner-scope caller-supplied session ids.
|
||||
|
||||
SessionManager.get_session returns any session by id (no owner scoping). The
|
||||
/api/memory extract, audit, import, and by-session handlers accept a
|
||||
caller-supplied session id, so without an ownership gate a user could target
|
||||
another tenant's session and leak their chat history, session-scoped LLM
|
||||
credentials, or session title.
|
||||
"""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import routes.memory_routes as mr
|
||||
|
||||
|
||||
def _route(router, path, method):
|
||||
for r in router.routes:
|
||||
if r.path == path and method in getattr(r, "methods", set()):
|
||||
return r.endpoint
|
||||
raise AssertionError(path)
|
||||
|
||||
|
||||
def _router(monkeypatch, caller):
|
||||
monkeypatch.setattr(mr, "get_current_user", lambda request: caller, raising=False)
|
||||
monkeypatch.setattr(mr, "require_user", lambda request: caller, raising=False)
|
||||
sm = MagicMock()
|
||||
sm.sessions = {}
|
||||
sm.get_session = lambda sid: SimpleNamespace(
|
||||
owner="alice", name="Secret project", endpoint_url="http://x", model="m",
|
||||
headers={"Authorization": "Bearer victim-secret"},
|
||||
get_context_messages=lambda: [],
|
||||
)
|
||||
mem = MagicMock()
|
||||
mem.load = lambda owner=None: []
|
||||
return mr.setup_memory_routes(mem, sm)
|
||||
|
||||
|
||||
def test_extract_rejects_other_users_session(monkeypatch):
|
||||
router = _router(monkeypatch, caller="bob")
|
||||
extract = _route(router, "/api/memory/extract", "POST")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(extract(request=None, session="alice-sess"))
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_by_session_rejects_other_users_session(monkeypatch):
|
||||
router = _router(monkeypatch, caller="bob")
|
||||
gbs = _route(router, "/api/memory/by-session/{session_id}", "GET")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
gbs(request=None, session_id="alice-sess")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_owner_can_access_own_session(monkeypatch):
|
||||
router = _router(monkeypatch, caller="alice")
|
||||
gbs = _route(router, "/api/memory/by-session/{session_id}", "GET")
|
||||
out = gbs(request=None, session_id="alice-sess")
|
||||
assert out["session_name"] == "Secret project"
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Owner-scope regression for /api/research/start endpoint resolution.
|
||||
|
||||
`research_start()` resolves a CALLER-SUPPLIED `endpoint_id` (and, with nothing
|
||||
configured, a bare first-enabled fallback) to a `ModelEndpoint` whose *decrypted*
|
||||
api_key + base_url then drive the research LLM calls
|
||||
(`start_research(llm_endpoint=, llm_headers=)`). Both lookups must be
|
||||
owner-scoped — the caller's own rows plus legacy null-owner ("shared") rows —
|
||||
so a research-privileged user (or a chat-scoped token) can't bind a research run
|
||||
to ANOTHER user's PRIVATE endpoint and silently spend that owner's API key /
|
||||
reach whatever internal base_url they configured. Mirrors the
|
||||
webhook `_first_enabled_endpoint` (#1045) and session `_owned_endpoint` fixes.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# The helper resolves `from src.database import ModelEndpoint` at call time.
|
||||
# Stub the module so we can hand it a fake declarative class whose column
|
||||
# comparisons return inspectable predicates (the real one is a SQLAlchemy
|
||||
# class, MagicMock'd to oblivion by conftest). owner_filter stays REAL.
|
||||
_sd = types.ModuleType("src.database")
|
||||
_sd.ModelEndpoint = MagicMock()
|
||||
sys.modules.setdefault("src.database", _sd)
|
||||
|
||||
from routes.research_routes import _owned_enabled_endpoint # noqa: E402
|
||||
|
||||
|
||||
class _Predicate:
|
||||
def __init__(self, check):
|
||||
self._check = check
|
||||
|
||||
def __call__(self, row):
|
||||
return self._check(row)
|
||||
|
||||
def __or__(self, other):
|
||||
return _Predicate(lambda row: self(row) or other(row))
|
||||
|
||||
|
||||
class _Column:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, value):
|
||||
return _Predicate(lambda row: getattr(row, self.name) == value)
|
||||
|
||||
|
||||
class _ModelEndpoint:
|
||||
id = _Column("id")
|
||||
is_enabled = _Column("is_enabled")
|
||||
owner = _Column("owner")
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *predicates):
|
||||
self._rows = [r for r in self._rows if all(p(r) for p in predicates)]
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def query(self, model):
|
||||
assert model is _ModelEndpoint
|
||||
return _Query(self._rows)
|
||||
|
||||
|
||||
def _ep(eid, owner, *, is_enabled=True):
|
||||
return SimpleNamespace(id=eid, owner=owner, is_enabled=is_enabled, api_key="sk-secret")
|
||||
|
||||
|
||||
def _resolve(rows, owner, endpoint_id=None):
|
||||
sys.modules["src.database"].ModelEndpoint = _ModelEndpoint
|
||||
return _owned_enabled_endpoint(_DB(rows), owner, endpoint_id)
|
||||
|
||||
|
||||
# --- explicit endpoint_id (POST /api/research/start, body.endpoint_id) --------
|
||||
|
||||
def test_endpoint_id_rejects_another_owners_private_endpoint():
|
||||
# bob's private endpoint exists, but alice asking for it by id resolves None
|
||||
# → the route raises 404 ("Endpoint not found or disabled"), never builds
|
||||
# headers from bob's key.
|
||||
rows = [_ep("ep-bob", "bob"), _ep("ep-alice", "alice")]
|
||||
assert _resolve(rows, "alice", "ep-bob") is None
|
||||
|
||||
|
||||
def test_endpoint_id_returns_callers_own_endpoint():
|
||||
rows = [_ep("ep-bob", "bob"), _ep("ep-alice", "alice")]
|
||||
ep = _resolve(rows, "alice", "ep-alice")
|
||||
assert ep is not None and ep.id == "ep-alice"
|
||||
|
||||
|
||||
def test_endpoint_id_allows_legacy_null_owner_shared_row():
|
||||
rows = [_ep("ep-shared", None)]
|
||||
ep = _resolve(rows, "alice", "ep-shared")
|
||||
assert ep is not None and ep.id == "ep-shared"
|
||||
|
||||
|
||||
def test_endpoint_id_skips_disabled_even_when_owned():
|
||||
rows = [_ep("ep-alice", "alice", is_enabled=False)]
|
||||
assert _resolve(rows, "alice", "ep-alice") is None
|
||||
|
||||
|
||||
# --- bare first-enabled fallback (no endpoint_id, nothing configured) ---------
|
||||
|
||||
def test_fallback_never_picks_another_owners_endpoint():
|
||||
# bob's private endpoint is first in the table, alice must never borrow it.
|
||||
rows = [_ep("ep-bob", "bob"), _ep("ep-shared", None)]
|
||||
ep = _resolve(rows, "alice")
|
||||
assert ep is not None and ep.id == "ep-shared"
|
||||
|
||||
|
||||
def test_fallback_returns_none_when_only_others_endpoints():
|
||||
rows = [_ep("ep-bob", "bob"), _ep("ep-carol", "carol")]
|
||||
assert _resolve(rows, "alice") is None
|
||||
|
||||
|
||||
# --- legacy single-user / unresolved owner: owner_filter no-op ---------------
|
||||
|
||||
def test_null_owner_is_legacy_single_user_noop():
|
||||
rows = [_ep("ep-x", "bob"), _ep("ep-y", "alice")]
|
||||
ep = _resolve(rows, None, "ep-x")
|
||||
assert ep is not None and ep.id == "ep-x"
|
||||
@@ -0,0 +1,37 @@
|
||||
import ipaddress
|
||||
|
||||
import pytest
|
||||
|
||||
from services.search import content as service_content
|
||||
from src.search import content as src_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", [src_content, service_content])
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://printer.local/",
|
||||
"http://nas.lan/",
|
||||
"http://admin.internal/",
|
||||
"http://service.intranet/",
|
||||
"http://[::ffff:169.254.169.254]/latest/meta-data/",
|
||||
"http://224.0.0.1/",
|
||||
"http://[ff02::1]/",
|
||||
"http://[::]/",
|
||||
])
|
||||
def test_search_content_url_guard_blocks_internal_names_and_address_classes(module, url):
|
||||
assert module._public_http_url(url) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", [src_content, service_content])
|
||||
def test_search_content_url_guard_blocks_dns_to_multicast(monkeypatch, module):
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_resolve_hostname_ips",
|
||||
lambda host: [ipaddress.ip_address("224.0.0.1")],
|
||||
)
|
||||
|
||||
assert module._public_http_url("https://example.test/page") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", [src_content, service_content])
|
||||
def test_search_content_url_guard_still_allows_public_ip(module):
|
||||
assert module._public_http_url("https://93.184.216.34/") is True
|
||||
@@ -1,6 +1,23 @@
|
||||
import pytest
|
||||
import sys
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def _drop_fake_core_database():
|
||||
parent = sys.modules.get("core")
|
||||
attr = getattr(parent, "database", None) if parent is not None else None
|
||||
mod = sys.modules.get("core.database") or attr
|
||||
if mod is None or isinstance(getattr(mod, "__file__", None), str):
|
||||
return
|
||||
sys.modules.pop("core.database", None)
|
||||
sys.modules.pop("src.database", None)
|
||||
if parent is not None and attr is mod:
|
||||
delattr(parent, "database")
|
||||
|
||||
|
||||
_drop_fake_core_database()
|
||||
|
||||
from core.database import Base, Session, ChatMessage
|
||||
from datetime import datetime
|
||||
|
||||
@@ -35,4 +52,3 @@ def test_sqlite_foreign_keys_cascade():
|
||||
assert db.query(ChatMessage).count() == 0
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Regression tests for task-result delivery into chat sessions (issue #326)."""
|
||||
import asyncio
|
||||
import sys
|
||||
import types as _types
|
||||
|
||||
import pytest
|
||||
@@ -11,6 +12,22 @@ if not isinstance(sqlalchemy, _types.ModuleType):
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def _drop_fake_core_database():
|
||||
parent = sys.modules.get("core")
|
||||
attr = getattr(parent, "database", None) if parent is not None else None
|
||||
mod = sys.modules.get("core.database") or attr
|
||||
if mod is None or isinstance(getattr(mod, "__file__", None), str):
|
||||
return
|
||||
sys.modules.pop("core.database", None)
|
||||
sys.modules.pop("src.database", None)
|
||||
if parent is not None and attr is mod:
|
||||
delattr(parent, "database")
|
||||
|
||||
|
||||
_drop_fake_core_database()
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import Base, Session as DbSession
|
||||
from src.task_scheduler import TaskScheduler
|
||||
|
||||
@@ -46,10 +63,15 @@ def _make_task():
|
||||
)
|
||||
|
||||
|
||||
def test_session_delivery_survives_empty_database():
|
||||
def test_session_delivery_survives_empty_database(monkeypatch):
|
||||
"""On a fresh/wiped database there is no session to inherit endpoint/model
|
||||
from, so _resolve_defaults returns None. The delivery must still persist a
|
||||
session instead of crashing on the NOT NULL constraint (issue #326)."""
|
||||
monkeypatch.setitem(sys.modules, "core.database", cdb)
|
||||
parent = sys.modules.get("core")
|
||||
if parent is not None:
|
||||
monkeypatch.setattr(parent, "database", cdb, raising=False)
|
||||
|
||||
db = _make_db()
|
||||
scheduler = TaskScheduler.__new__(TaskScheduler)
|
||||
scheduler._session_manager = None
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
"""Tests for topic keyword matching (src/topic_analyzer.py)."""
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def _drop_fake_core_database():
|
||||
parent = sys.modules.get("core")
|
||||
attr = getattr(parent, "database", None) if parent is not None else None
|
||||
mod = sys.modules.get("core.database") or attr
|
||||
if mod is None or isinstance(getattr(mod, "__file__", None), str):
|
||||
return
|
||||
sys.modules.pop("core.database", None)
|
||||
sys.modules.pop("src.database", None)
|
||||
if parent is not None and attr is mod:
|
||||
delattr(parent, "database")
|
||||
|
||||
|
||||
_drop_fake_core_database()
|
||||
|
||||
from core.database import Base, Session as DbSession, ChatMessage as DbChatMessage
|
||||
from core.session_manager import SessionManager
|
||||
from src.topic_analyzer import analyze_topics
|
||||
|
||||
@@ -6,6 +6,22 @@ from datetime import datetime
|
||||
# from it, so drop the stub here to load the real module under test.
|
||||
if "src.database" in sys.modules:
|
||||
del sys.modules["src.database"]
|
||||
_core_database = sys.modules.get("core.database")
|
||||
_core_database_all = getattr(_core_database, "__all__", None) if _core_database is not None else None
|
||||
if (
|
||||
_core_database is not None
|
||||
and (
|
||||
not getattr(_core_database, "__file__", None)
|
||||
or (
|
||||
_core_database_all is not None
|
||||
and (
|
||||
not isinstance(_core_database_all, (list, tuple, set))
|
||||
or not all(isinstance(name, str) for name in _core_database_all)
|
||||
)
|
||||
)
|
||||
)
|
||||
):
|
||||
del sys.modules["core.database"]
|
||||
|
||||
import pytest
|
||||
from src.webhook_manager import validate_webhook_url
|
||||
|
||||
Reference in New Issue
Block a user