mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 11:52:25 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb940f24a3 |
@@ -178,6 +178,14 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse as _JSONResponse
|
from starlette.responses import JSONResponse as _JSONResponse
|
||||||
|
import anyio as _anyio
|
||||||
|
from starlette.requests import ClientDisconnect as _ClientDisconnect
|
||||||
|
|
||||||
|
# SSE endpoints (e.g. /api/editor/bridge/events) end/abort mid-stream when the
|
||||||
|
# browser disconnects; Starlette's BaseHTTPMiddleware surfaces that as
|
||||||
|
# anyio.EndOfStream (and sometimes ClientDisconnect). Those are client-side
|
||||||
|
# events, not server errors, so we map them to a 499 instead of a 500 traceback.
|
||||||
|
_STREAM_CLIENT_EXC = (_anyio.EndOfStream, _ClientDisconnect)
|
||||||
|
|
||||||
REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45"))
|
REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45"))
|
||||||
_TIMEOUT_EXEMPT_PREFIXES = (
|
_TIMEOUT_EXEMPT_PREFIXES = (
|
||||||
@@ -190,6 +198,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
|
|||||||
"/api/cookbook/setup", # remote pacman/apt installs
|
"/api/cookbook/setup", # remote pacman/apt installs
|
||||||
"/api/upload", # large files
|
"/api/upload", # large files
|
||||||
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
||||||
|
"/api/editor", # editor agent/bridge: generative steps can take 30-110s (SAM + local diffusion)
|
||||||
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -225,15 +234,43 @@ class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
# Heavy image ops that can run 30-150 s; a deploy restart must not cut these off.
|
||||||
|
_HEAVY_IMAGE_OP_PREFIXES = (
|
||||||
|
"/api/image/inpaint",
|
||||||
|
"/api/image/replace-background",
|
||||||
|
"/api/image/outpaint",
|
||||||
|
"/api/image/extend",
|
||||||
|
"/api/image/remove-bg",
|
||||||
|
"/api/image/mask",
|
||||||
|
"/api/image/upscale-local",
|
||||||
|
"/api/image/denoise",
|
||||||
|
"/api/image/enhance-face",
|
||||||
|
"/api/gallery/ai-upscale",
|
||||||
|
"/api/gallery/style-transfer",
|
||||||
|
"/api/editor/agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request, call_next):
|
async def dispatch(self, request, call_next):
|
||||||
|
from src import activity
|
||||||
|
path = request.url.path or ""
|
||||||
|
heavy = any(path.startswith(p) for p in _HEAVY_IMAGE_OP_PREFIXES)
|
||||||
|
if heavy:
|
||||||
|
activity.begin_image_op(path)
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
status = 500
|
status = 500
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
status = getattr(response, "status_code", 0) or 0
|
status = getattr(response, "status_code", 0) or 0
|
||||||
return response
|
return response
|
||||||
|
except _STREAM_CLIENT_EXC:
|
||||||
|
# Client disconnected / SSE stream ended — not a server error.
|
||||||
|
status = 499
|
||||||
|
return _JSONResponse({"detail": "client disconnected"}, status_code=499)
|
||||||
finally:
|
finally:
|
||||||
|
if heavy:
|
||||||
|
activity.end_image_op(path)
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
try:
|
try:
|
||||||
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
||||||
@@ -821,6 +858,10 @@ app.include_router(setup_gallery_routes())
|
|||||||
from routes.editor_draft_routes import setup_editor_draft_routes
|
from routes.editor_draft_routes import setup_editor_draft_routes
|
||||||
app.include_router(setup_editor_draft_routes())
|
app.include_router(setup_editor_draft_routes())
|
||||||
|
|
||||||
|
# Natural-language editor planning (planner wired to the configured chat model)
|
||||||
|
from routes.editor_agent_routes import setup_editor_agent_routes
|
||||||
|
app.include_router(setup_editor_agent_routes())
|
||||||
|
|
||||||
# Scheduled tasks + event bus
|
# Scheduled tasks + event bus
|
||||||
from src.task_scheduler import TaskScheduler
|
from src.task_scheduler import TaskScheduler
|
||||||
task_scheduler = TaskScheduler(session_manager)
|
task_scheduler = TaskScheduler(session_manager)
|
||||||
@@ -992,12 +1033,21 @@ async def serve_login(request: Request):
|
|||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
async def get_version():
|
async def get_version():
|
||||||
from core.constants import APP_BUILD_VERSION, APP_SOURCE_COMMIT, APP_VERSION
|
from core.constants import APP_BUILD_VERSION, APP_SOURCE_COMMIT, APP_VERSION
|
||||||
|
from src import activity
|
||||||
return {
|
return {
|
||||||
"version": APP_VERSION,
|
"version": APP_VERSION,
|
||||||
"build": APP_BUILD_VERSION,
|
"build": APP_BUILD_VERSION,
|
||||||
"source_commit": APP_SOURCE_COMMIT,
|
"source_commit": APP_SOURCE_COMMIT,
|
||||||
|
"busy_image_ops": activity.heavy_image_ops_active(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/busy")
|
||||||
|
async def get_busy():
|
||||||
|
"""Report in-flight heavy work so a deploy can wait instead of dropping it."""
|
||||||
|
from src import activity
|
||||||
|
return activity.activity()
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check() -> Dict[str, str]:
|
async def health_check() -> Dict[str, str]:
|
||||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||||
@@ -1436,4 +1486,35 @@ if __name__ == "__main__":
|
|||||||
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
||||||
bind_port = int(os.getenv("APP_PORT", "7011"))
|
bind_port = int(os.getenv("APP_PORT", "7011"))
|
||||||
|
|
||||||
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
# Give in-flight requests time to finish on SIGTERM instead of being cut
|
||||||
|
# off mid-flight. Diffusion edits run 60-120 s; outpaint/extend can run up
|
||||||
|
# to ~330 s (provider cap 900 s), so default the graceful window to 900 s.
|
||||||
|
# systemd's TimeoutStopSec must exceed this (see the graceful-stop drop-in).
|
||||||
|
#
|
||||||
|
# We hook uvicorn's signal handler so a SIGTERM also flips src.shutdown:
|
||||||
|
# SSE / streaming generators check it and end promptly, so an open browser
|
||||||
|
# stream cannot stall a restart for the whole window. The long window stays
|
||||||
|
# a safety net for genuine in-flight image ops.
|
||||||
|
from src import shutdown as _shutdown
|
||||||
|
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app,
|
||||||
|
host=bind_host,
|
||||||
|
port=bind_port,
|
||||||
|
log_level="info",
|
||||||
|
timeout_graceful_shutdown=int(os.getenv("ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900") or 900),
|
||||||
|
)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
|
||||||
|
# uvicorn's capture_signals() wires SIGINT/SIGTERM to self.handle_exit, so
|
||||||
|
# wrapping that method lets us set the shutdown flag before the normal exit
|
||||||
|
# path runs (this uvicorn version has no install_signal_handlers kwarg).
|
||||||
|
_uvicorn_handle_exit = server.handle_exit
|
||||||
|
|
||||||
|
def _handle_shutdown_signal(signum, frame):
|
||||||
|
_shutdown.request_shutdown()
|
||||||
|
_uvicorn_handle_exit(signum, frame)
|
||||||
|
|
||||||
|
server.handle_exit = _handle_shutdown_signal
|
||||||
|
|
||||||
|
server.run()
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Editor Agent API
|
||||||
|
|
||||||
|
How an agent (LLM or script) drives the PhotoCraft editor. There are three layers:
|
||||||
|
|
||||||
|
1. **Control API** — `window.geEditorControl`, a stable in-page command surface.
|
||||||
|
2. **NL planner** — `window.geEditorAI`, turns a prompt into control-API steps (LLM or basic mode).
|
||||||
|
3. **Bridge** — server-mediated SSE channel so a *server-side* agent can drive an **open** editor.
|
||||||
|
|
||||||
|
All three are owner-scoped by the app session; nothing is exposed to unauthenticated users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Control API (`window.geEditorControl`)
|
||||||
|
|
||||||
|
Installed when the editor opens. Version `EDITOR_CONTROL_VERSION`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
await window.geEditorControl.invoke(op, params) // { ok, op, result } | { ok:false, op, error }
|
||||||
|
await window.geEditorControl.run([{op, params}]) // sequential, stops on first failure
|
||||||
|
window.geEditorControl.list() // [{op, description}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`invoke` is async. Every op that mutates the document pushes **one** undo entry via
|
||||||
|
`_saveState`, so a single op is one undo step.
|
||||||
|
|
||||||
|
### Op catalog
|
||||||
|
|
||||||
|
| Op | Params | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `document.info` | – | size, layers, mode |
|
||||||
|
| `document.importImage` | `{dataUrl,name,raster}` | load a raster image as a new layer |
|
||||||
|
| `layers.list` | – | top-most first |
|
||||||
|
| `layers.select` | `{id\|index\|name}` | index is top-most-first |
|
||||||
|
| `layers.addRaster` | – | new empty pixel layer |
|
||||||
|
| `layers.addAdjustment` | `{type,params}` | adjustment layer |
|
||||||
|
| `layers.duplicate` / `layers.delete` | – | on the active/selected layer(s) |
|
||||||
|
| `layers.setOpacity` | `{id?,opacity 0-1}` | |
|
||||||
|
| `layers.setBlend` | `{id?,blendMode\|mode}` | |
|
||||||
|
| `layers.group` / `layers.ungroup` | – | |
|
||||||
|
| `selection.selectAll` / `deselect` / `invert` | – | |
|
||||||
|
| `selection.rectangle` / `selection.ellipse` | `{x,y,w,h}` | document space |
|
||||||
|
| `selection.subject` | `{label}` | SAM text grounding (falls back to a centred rect) |
|
||||||
|
| `selection.face` | – | SAM "face" (falls back to an upper-centre ellipse) |
|
||||||
|
| `selection.sky` | – | SAM "sky" (falls back to a top-band colour range) |
|
||||||
|
| `selection.background` | `{fuzziness}` | border colour union |
|
||||||
|
| `selection.samText` | `{text}` | SAM text grounding only (throws without SAM) |
|
||||||
|
| `selection.colorRange` | `{color,fuzziness}` | |
|
||||||
|
| `selection.hasMask` | – | `{active, space}` |
|
||||||
|
| `adjustments.apply` | `{type,params}` | adjustment layer; camelCase types accepted |
|
||||||
|
| `adjustments.applyMasked` | `{brightness,contrast,saturation,warmth,invert}` | pixel pass, masked if a selection exists else whole layer |
|
||||||
|
| `filters.apply` | `{id}` | opens the slider dialog and applies |
|
||||||
|
| `filters.applyMasked` | `{id,radius,amount,invert}` | `id`: blur-gaussian, sharpen, unsharp-mask |
|
||||||
|
| `transform.start` / `transform.apply` / `transform.cancel` | – | |
|
||||||
|
| `text.add` | `{text,x,y,color,size,font,align}` | |
|
||||||
|
| `history.undo` / `history.redo` | – | |
|
||||||
|
| `history.mark` | – | returns current undo depth (for grouping) |
|
||||||
|
| `history.collapse` | `{mark,label}` | collapses entries above `mark` into one |
|
||||||
|
| `export.png` | – | `{dataUrl}` of the flattened document |
|
||||||
|
| `export.gallery` | – | saves a copy back to the gallery |
|
||||||
|
| `screenshot` | – | `{dataUrl,width,height}` of the composited canvas |
|
||||||
|
|
||||||
|
Param conventions the planner must respect:
|
||||||
|
|
||||||
|
- `adjustments.applyMasked`: `brightness`/`contrast`/`warmth` are **-100..100** (0 neutral);
|
||||||
|
`saturation` is a **multiplier** (1 neutral; values >3 are treated as percentages).
|
||||||
|
- `adjustments.apply` `type` aliases are normalised (`blackAndWhite` -> `black-white`).
|
||||||
|
- `color-balance` params are `{shadows:{r,g,b}, midtones:{r,g,b}, highlights:{r,g,b}}`;
|
||||||
|
a bare number is coerced to `{r:n,g:n,b:n}`.
|
||||||
|
- A masked op with **no** active selection degrades to a whole-layer edit (does not throw).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. NL planner (`window.geEditorAI`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await window.geEditorAI.execute('blur the background behind the person', { onStep });
|
||||||
|
// exec: window.geEditorAI.execute('blur the background behind the person', { onStep });
|
||||||
|
// res = { ok, prompt, reply, planner, basicMode, steps, trace, failed }
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow (`static/js/editor/nl-edit.js`):
|
||||||
|
|
||||||
|
1. Read document state + a **before** screenshot.
|
||||||
|
2. Ask the **server planner** (`POST /api/editor/plan`) with the prompt, state and
|
||||||
|
screenshot. The client passes its strong guidance via `state.planner_guidance`.
|
||||||
|
If the route is unavailable it falls back to `localPlan` (regex — labelled
|
||||||
|
**"basic mode"** in the UI).
|
||||||
|
3. `sanitizeSteps()`: drop no-op/export steps and, if a masked op has no preceding
|
||||||
|
`selection.*`, insert the region implied by the prompt (face/sky/background/subject).
|
||||||
|
4. `history.mark`, then run each step, screenshotting after each one.
|
||||||
|
5. **Self-check** (server plans only, max 3 rounds): send the after screenshot back in
|
||||||
|
`mode:"check"` with the original intent. If not satisfied, **revert** the previous
|
||||||
|
attempt (undo its history entries) and apply the model's **full corrected** plan.
|
||||||
|
6. `history.collapse` — the whole request becomes a single undoable step.
|
||||||
|
|
||||||
|
UI: the Quick Edit box routes multi-clause / "and ..." prompts to this executor and
|
||||||
|
streams `Step i/n: <op>` into its status line; a `(basic mode)` suffix marks the offline planner.
|
||||||
|
|
||||||
|
### Server route
|
||||||
|
|
||||||
|
`POST /api/editor/plan` (HARNESS, `routes/editor_agent_routes.py`)
|
||||||
|
|
||||||
|
```
|
||||||
|
{ prompt, state, mode?: "plan"|"check", screenshot?: "data:image/..." }
|
||||||
|
-> { reply, planner, steps: [{op, params}], done? }
|
||||||
|
```
|
||||||
|
|
||||||
|
Owner-scoped, authenticated. Uses the configured default/utility model with vision.
|
||||||
|
If the model call is unavailable it returns a deterministic `planner:"fallback"` plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Bridge (server agent drives an open editor)
|
||||||
|
|
||||||
|
`static/js/editor/editor-bridge.js` subscribes the open editor to the server queue.
|
||||||
|
Enabled automatically when the editor opens (`window.__geEditorBridge`).
|
||||||
|
|
||||||
|
- Subscribe: `GET /api/editor/bridge/events?session=<id>` — SSE, `data: {id,op,params}` (keepalive every 15s).
|
||||||
|
- The page runs the op via the control API and replies:
|
||||||
|
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
||||||
|
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
||||||
|
With `wait:true` the call blocks and returns the editor's result.
|
||||||
|
|
||||||
|
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
||||||
|
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
||||||
|
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}'
|
||||||
|
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
Session scoping is `owner:session`, so an agent can only drive editors owned by the
|
||||||
|
same user. The bridge is a **browser-only** flow: it requires an editor page to be open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SAM dependency
|
||||||
|
|
||||||
|
`selection.subject` / `selection.face` / `selection.sky` / `selection.samText` use the
|
||||||
|
server SAM mask tool (`POST /api/image/mask`). When the SAM tools are **not installed**
|
||||||
|
the editor raises *"Object mask tools are not installed"* and the control op falls back
|
||||||
|
to a **coarse approximation** (centred rect / upper-centre ellipse / top-band colour range).
|
||||||
|
On a plain background the approximation is acceptable; on real photos it leaks, producing
|
||||||
|
visible rectangular/elliptical artifacts. Install the Cookbook SAM mask dependencies for
|
||||||
|
true subject/face/sky segmentation.
|
||||||
|
|
||||||
|
`selection.colorRange` and `selection.background` never need SAM (pure colour matching),
|
||||||
|
but they are also coarse on busy scenes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Two ways for an agent to use this — pick one
|
||||||
|
|
||||||
|
### Option A — built-in tool (in-process)
|
||||||
|
|
||||||
|
Register an editor-control tool inside the app (same process as the model) that calls the
|
||||||
|
bridge/control API directly. Uses the app's normal login; nothing new is exposed. This is
|
||||||
|
the rejected stdio-MCP design's replacement: a server-side tool that talks to the bridge
|
||||||
|
needs either an internal token (auth-bypass) or to run in-process. **Decision pending (user).**
|
||||||
|
|
||||||
|
### Option B — browser-only flow (works today)
|
||||||
|
|
||||||
|
No new server tool. An external agent drives an open editor purely through the bridge
|
||||||
|
endpoints using the logged-in owner's session. Restricted to the editor the user has open.
|
||||||
|
This is the flow demonstrated above and requires **no** auth-bypass.
|
||||||
|
|
||||||
|
A stdio `photo_editor` MCP server was prototyped and **rejected** because the built-in MCP
|
||||||
|
subprocess has no internal token; wiring it would require expanding an auth bypass.
|
||||||
|
The two viable options above are left for the user to choose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Known limitations / honest grading
|
||||||
|
|
||||||
|
Live run against real 7011 with the server LLM planner (7 prompts): all 7 executed
|
||||||
|
(`planner:"llm"`). Graded by eye:
|
||||||
|
|
||||||
|
- PASS: black & white portrait; warm cinematic; sharper + more vivid; text title.
|
||||||
|
- FAIL (region-masked, SAM-bound): "dramatic sky" (global over-saturation instead of a
|
||||||
|
dramatic, masked sky), "blur the background" (coarse mask leaks; visible rectangle),
|
||||||
|
"brighten the face" (coarse ellipse/rect artifact).
|
||||||
|
|
||||||
|
Whole-image edits are reliable; subject/face/sky region edits need SAM. The self-check
|
||||||
|
loop works (revert-and-replan) but the region cases spend all 3 iterations.
|
||||||
|
|
||||||
|
## 7. Theming (control kit)
|
||||||
|
|
||||||
|
The editor's interactive controls (ranges, number fields, selects, checkboxes) share a
|
||||||
|
single themed kit: `static/js/editor/control-kit.js` adds `.ge-ctl-*` classes scoped to
|
||||||
|
the editor roots (`#gallery-editor-container`, `.ge-adj-popup`, `.ge-filter-overlay`,
|
||||||
|
`.ge-canvas-size-overlay`, `.ge-float-window`, `.ge-transform-popup`), and
|
||||||
|
`static/css/13-image-editor.css` (section "Editor control kit") styles them from the app
|
||||||
|
design tokens (`static/css/00-tokens.css`) to match the app sliders/selects. It keeps
|
||||||
|
range fills in sync and attaches a **scrub-on-label** affordance to numeric fields
|
||||||
|
(drag the field's label to change the value; arrow keys work, Shift = x10).
|
||||||
|
|
||||||
|
**The PhotoCraft editor is dark-only.** `:root.light` flips the global tokens, but
|
||||||
|
`13-image-editor.css` has no `.light` handling and the editor relies on dark surfaces,
|
||||||
|
so light mode renders a garbled mix. Unlike the rest of Odysseus, the editor follows
|
||||||
|
Photoshop and is not offered in a light theme.
|
||||||
File diff suppressed because it is too large
Load Diff
+16
-153
@@ -17,8 +17,6 @@ import base64
|
|||||||
import time
|
import time
|
||||||
import imaplib
|
import imaplib
|
||||||
import smtplib
|
import smtplib
|
||||||
import socket
|
|
||||||
import ssl
|
|
||||||
import email as email_mod
|
import email as email_mod
|
||||||
import email.header
|
import email.header
|
||||||
import email.utils
|
import email.utils
|
||||||
@@ -38,7 +36,6 @@ from typing import Optional, List
|
|||||||
|
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
from src.secret_storage import decrypt as _decrypt
|
from src.secret_storage import decrypt as _decrypt
|
||||||
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -163,94 +160,6 @@ def _smtp_security_mode(cfg: dict) -> str:
|
|||||||
return "ssl"
|
return "ssl"
|
||||||
|
|
||||||
|
|
||||||
# Raised when a mail host resolves into a denied range (see _mail_private_blocked).
|
|
||||||
MailServerAddressBlocked = OutboundAddressBlocked
|
|
||||||
|
|
||||||
|
|
||||||
def _mail_private_blocked(owner: str | None) -> bool:
|
|
||||||
"""Whether private/loopback/shared mail destinations are denied for *owner*.
|
|
||||||
|
|
||||||
Link-local (cloud metadata), multicast, reserved and unspecified addresses
|
|
||||||
are always denied (src/url_safety.py). Private ranges are where a mail
|
|
||||||
tester or saved account becomes an internal-network probe, so they are
|
|
||||||
only allowed for an admin or in single-user mode (AUTH_ENABLED=false), the
|
|
||||||
same principals trusted with host-level tools. Operators of multi-user
|
|
||||||
deployments with a LAN mail server opt in with EMAIL_ALLOW_PRIVATE_IPS=true;
|
|
||||||
EMAIL_BLOCK_PRIVATE_IPS=true denies private ranges for everyone.
|
|
||||||
"""
|
|
||||||
if os.getenv("EMAIL_BLOCK_PRIVATE_IPS", "").strip().lower() == "true":
|
|
||||||
return True
|
|
||||||
if os.getenv("EMAIL_ALLOW_PRIVATE_IPS", "").strip().lower() == "true":
|
|
||||||
return False
|
|
||||||
from src.tool_security import owner_is_admin_or_single_user
|
|
||||||
|
|
||||||
return not owner_is_admin_or_single_user((owner or "").strip() or None)
|
|
||||||
|
|
||||||
|
|
||||||
def _mail_socket_timeout(timeout):
|
|
||||||
return socket._GLOBAL_DEFAULT_TIMEOUT if timeout is None else timeout
|
|
||||||
|
|
||||||
|
|
||||||
class _PolicyIMAP4(imaplib.IMAP4):
|
|
||||||
"""IMAP4 whose socket comes from connect_outbound_tcp.
|
|
||||||
|
|
||||||
The mail host is resolved once and connected to only at the addresses the
|
|
||||||
address policy approved, so DNS rebinding between check and connect is
|
|
||||||
impossible. STARTTLS still verifies against ``self.host``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, host, port, *, block_private: bool, timeout=None):
|
|
||||||
self._block_private = block_private
|
|
||||||
super().__init__(host, port, timeout=timeout)
|
|
||||||
|
|
||||||
def _create_socket(self, timeout):
|
|
||||||
if timeout is not None and not timeout:
|
|
||||||
raise ValueError("Non-blocking socket (timeout=0) is not supported")
|
|
||||||
return connect_outbound_tcp(
|
|
||||||
self.host, self.port, timeout=_mail_socket_timeout(timeout), block_private=self._block_private,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _PolicyIMAP4_SSL(imaplib.IMAP4_SSL):
|
|
||||||
"""IMAP4_SSL over a policy-checked socket; TLS SNI/verification use the hostname."""
|
|
||||||
|
|
||||||
def __init__(self, host, port, *, block_private: bool, timeout=None, ssl_context=None):
|
|
||||||
self._block_private = block_private
|
|
||||||
super().__init__(host, port, ssl_context=ssl_context, timeout=timeout)
|
|
||||||
|
|
||||||
def _create_socket(self, timeout):
|
|
||||||
sock = _PolicyIMAP4._create_socket(self, timeout)
|
|
||||||
return self.ssl_context.wrap_socket(sock, server_hostname=self.host)
|
|
||||||
|
|
||||||
|
|
||||||
class _PolicySMTP(smtplib.SMTP):
|
|
||||||
"""SMTP whose socket comes from connect_outbound_tcp (see _PolicyIMAP4)."""
|
|
||||||
|
|
||||||
def __init__(self, host="", port=0, *, block_private: bool, **kwargs):
|
|
||||||
self._block_private = block_private
|
|
||||||
super().__init__(host, port, **kwargs)
|
|
||||||
|
|
||||||
def _get_socket(self, host, port, timeout):
|
|
||||||
if timeout is not None and not timeout:
|
|
||||||
raise ValueError("Non-blocking socket (timeout=0) is not supported")
|
|
||||||
return connect_outbound_tcp(
|
|
||||||
host, port, timeout=timeout, block_private=self._block_private,
|
|
||||||
source_address=self.source_address,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _PolicySMTP_SSL(smtplib.SMTP_SSL):
|
|
||||||
"""SMTP_SSL over a policy-checked socket; TLS SNI/verification use the hostname."""
|
|
||||||
|
|
||||||
def __init__(self, host="", port=0, *, block_private: bool, **kwargs):
|
|
||||||
self._block_private = block_private
|
|
||||||
super().__init__(host, port, **kwargs)
|
|
||||||
|
|
||||||
def _get_socket(self, host, port, timeout):
|
|
||||||
sock = _PolicySMTP._get_socket(self, host, port, timeout)
|
|
||||||
return self.context.wrap_socket(sock, server_hostname=self._host)
|
|
||||||
|
|
||||||
|
|
||||||
def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None:
|
def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None:
|
||||||
"""Send through SMTP using the configured transport security mode."""
|
"""Send through SMTP using the configured transport security mode."""
|
||||||
host = cfg["smtp_host"]
|
host = cfg["smtp_host"]
|
||||||
@@ -269,15 +178,14 @@ def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message
|
|||||||
smtp.login(user, password)
|
smtp.login(user, password)
|
||||||
|
|
||||||
security = _smtp_security_mode(cfg)
|
security = _smtp_security_mode(cfg)
|
||||||
block_private = _mail_private_blocked(cfg.get("owner"))
|
|
||||||
|
|
||||||
if security == "ssl":
|
if security == "ssl":
|
||||||
with _PolicySMTP_SSL(host, port, timeout=timeout, block_private=block_private) as smtp:
|
with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
|
||||||
_auth_smtp(smtp)
|
_auth_smtp(smtp)
|
||||||
smtp.sendmail(from_addr, recipients, message)
|
smtp.sendmail(from_addr, recipients, message)
|
||||||
return
|
return
|
||||||
|
|
||||||
with _PolicySMTP(host, port, timeout=timeout, block_private=block_private) as smtp:
|
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
||||||
if security == "starttls":
|
if security == "starttls":
|
||||||
smtp.starttls()
|
smtp.starttls()
|
||||||
_auth_smtp(smtp)
|
_auth_smtp(smtp)
|
||||||
@@ -316,39 +224,6 @@ def _friendly_email_auth_error(protocol: str, host: str, error: object) -> str:
|
|||||||
return raw[:200]
|
return raw[:200]
|
||||||
|
|
||||||
|
|
||||||
# Bound at import: callers (and tests) may swap out imaplib.IMAP4 itself.
|
|
||||||
_IMAP_ABORT = imaplib.IMAP4.abort
|
|
||||||
|
|
||||||
|
|
||||||
def _mail_connection_test_error(protocol: str, host: str, error: BaseException) -> str:
|
|
||||||
"""User-facing result for a failed IMAP/SMTP connection test.
|
|
||||||
|
|
||||||
Transport failures are reported by category, never with the peer's own
|
|
||||||
bytes: a non-mail service answering on the chosen host/port would
|
|
||||||
otherwise have its banner echoed back (an internal-service fingerprinting
|
|
||||||
oracle). Errors from a server that does speak the protocol (login/auth
|
|
||||||
rejections) keep their text via _friendly_email_auth_error, since that is
|
|
||||||
what the user needs to fix their settings.
|
|
||||||
"""
|
|
||||||
if isinstance(error, MailServerAddressBlocked):
|
|
||||||
return f"{protocol} server address is not allowed by this server's network policy"
|
|
||||||
if isinstance(error, (_IMAP_ABORT, smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected)):
|
|
||||||
return f"{protocol} server did not respond like an {protocol} server"
|
|
||||||
if isinstance(error, ssl.SSLCertVerificationError):
|
|
||||||
return f"{protocol} TLS certificate verification failed"
|
|
||||||
if isinstance(error, ssl.SSLError):
|
|
||||||
return f"{protocol} TLS handshake failed; check the port and security setting"
|
|
||||||
if isinstance(error, (socket.timeout, TimeoutError)):
|
|
||||||
return f"{protocol} connection timed out"
|
|
||||||
if isinstance(error, ConnectionRefusedError):
|
|
||||||
return f"{protocol} connection refused"
|
|
||||||
if isinstance(error, socket.gaierror):
|
|
||||||
return f"{protocol} server name could not be resolved"
|
|
||||||
if isinstance(error, OSError) and not isinstance(error, smtplib.SMTPException):
|
|
||||||
return f"{protocol} connection failed"
|
|
||||||
return _friendly_email_auth_error(protocol, host, error)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_think(text: str) -> str:
|
def _strip_think(text: str) -> str:
|
||||||
"""Email-flavored think strip — thin wrapper over the central helper.
|
"""Email-flavored think strip — thin wrapper over the central helper.
|
||||||
|
|
||||||
@@ -810,21 +685,18 @@ def _ensure_sender_signatures_table(conn):
|
|||||||
_lg.getLogger(__name__).warning(f"sender_signatures owner-migration skipped: {_mig_e}")
|
_lg.getLogger(__name__).warning(f"sender_signatures owner-migration skipped: {_mig_e}")
|
||||||
|
|
||||||
|
|
||||||
def attachment_extract_dir(folder: str, uid: str, *, owner: str, account_id: str | None) -> Path:
|
def attachment_extract_dir(folder: str, uid: str) -> Path:
|
||||||
"""Containment-safe extraction directory for one message's attachments.
|
"""Containment-safe extraction directory for an attachment.
|
||||||
|
|
||||||
IMAP UIDs are small per-mailbox counters, so `folder_uid` alone put every
|
`folder` and `uid` are user-controlled (query/path params). Flatten them to
|
||||||
user's and every account's "INBOX 42" in one directory, where extractions
|
a single safe path segment so a value like folder='../../tmp' can't escape
|
||||||
overwrote each other by filename (a path handed to one user's agent could
|
ATTACHMENTS_DIR, then assert containment as belt-and-suspenders."""
|
||||||
then hold another user's attachment). See src.mail_attachment_paths: the
|
key = re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}") or "_"
|
||||||
directory is keyed by (owner, account, folder, uid) and contained in
|
target = (ATTACHMENTS_DIR / key).resolve()
|
||||||
ATTACHMENTS_DIR whatever `folder`/`uid` hold."""
|
base = ATTACHMENTS_DIR.resolve()
|
||||||
from src.mail_attachment_paths import attachment_scope_dir
|
if target != base and base not in target.parents:
|
||||||
|
|
||||||
try:
|
|
||||||
return attachment_scope_dir(ATTACHMENTS_DIR, folder, uid, owner=owner, account_id=account_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, "Invalid attachment location")
|
raise HTTPException(400, "Invalid attachment location")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
def _init_scheduled_db():
|
def _init_scheduled_db():
|
||||||
@@ -1205,8 +1077,6 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
|
|||||||
cfg = {
|
cfg = {
|
||||||
"account_id": row.id,
|
"account_id": row.id,
|
||||||
"account_name": row.name,
|
"account_name": row.name,
|
||||||
# Principal for the outbound mail address policy.
|
|
||||||
"owner": row.owner or owner or "",
|
|
||||||
"smtp_host": row.smtp_host or "",
|
"smtp_host": row.smtp_host or "",
|
||||||
"smtp_port": int(row.smtp_port or 465),
|
"smtp_port": int(row.smtp_port or 465),
|
||||||
"smtp_security": _smtp_security_mode({"smtp_security": getattr(row, "smtp_security", ""), "smtp_port": row.smtp_port}),
|
"smtp_security": _smtp_security_mode({"smtp_security": getattr(row, "smtp_security", ""), "smtp_port": row.smtp_port}),
|
||||||
@@ -1240,7 +1110,6 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
|
|||||||
cfg = {
|
cfg = {
|
||||||
"account_id": resolved_id,
|
"account_id": resolved_id,
|
||||||
"account_name": "legacy",
|
"account_name": "legacy",
|
||||||
"owner": owner or "",
|
|
||||||
"smtp_host": settings.get("smtp_host", os.environ.get("SMTP_HOST", "")),
|
"smtp_host": settings.get("smtp_host", os.environ.get("SMTP_HOST", "")),
|
||||||
"smtp_port": int(settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")) or 465),
|
"smtp_port": int(settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")) or 465),
|
||||||
"smtp_security": _smtp_security_mode({
|
"smtp_security": _smtp_security_mode({
|
||||||
@@ -1304,16 +1173,11 @@ def _open_imap_connection(
|
|||||||
starttls: bool,
|
starttls: bool,
|
||||||
timeout: int = _IMAP_TIMEOUT_SECONDS,
|
timeout: int = _IMAP_TIMEOUT_SECONDS,
|
||||||
ssl_context=None,
|
ssl_context=None,
|
||||||
owner: str | None = None,
|
|
||||||
):
|
):
|
||||||
"""Open an IMAP connection using the configured security mode.
|
"""Open an IMAP connection using the configured security mode."""
|
||||||
|
|
||||||
`owner` is the principal the destination is judged for
|
|
||||||
(see _mail_private_blocked)."""
|
|
||||||
port = int(port or 993)
|
port = int(port or 993)
|
||||||
block_private = _mail_private_blocked(owner)
|
|
||||||
if starttls:
|
if starttls:
|
||||||
conn = _PolicyIMAP4(host, port, timeout=timeout, block_private=block_private)
|
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
if ssl_context:
|
if ssl_context:
|
||||||
conn.starttls(ssl_context=ssl_context)
|
conn.starttls(ssl_context=ssl_context)
|
||||||
@@ -1329,9 +1193,9 @@ def _open_imap_connection(
|
|||||||
raise
|
raise
|
||||||
elif port == 993:
|
elif port == 993:
|
||||||
kwargs = {"ssl_context": ssl_context} if ssl_context else {}
|
kwargs = {"ssl_context": ssl_context} if ssl_context else {}
|
||||||
conn = _PolicyIMAP4_SSL(host, port, timeout=timeout, block_private=block_private, **kwargs)
|
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout, **kwargs)
|
||||||
else:
|
else:
|
||||||
conn = _PolicyIMAP4(host, port, timeout=timeout, block_private=block_private)
|
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
conn.sock.settimeout(timeout)
|
conn.sock.settimeout(timeout)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1371,7 +1235,6 @@ def _imap_connect(account_id: str | None = None, owner: str = "",
|
|||||||
cfg["imap_port"],
|
cfg["imap_port"],
|
||||||
starttls=bool(cfg.get("imap_starttls")),
|
starttls=bool(cfg.get("imap_starttls")),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
owner=cfg.get("owner"),
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if cfg.get("oauth_provider") == "google":
|
if cfg.get("oauth_provider") == "google":
|
||||||
|
|||||||
+242
-207
@@ -36,10 +36,10 @@ from pathlib import Path
|
|||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
from starlette.concurrency import run_in_threadpool
|
|
||||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
|
from src import shutdown as _shutdown
|
||||||
from src.path_confinement import confine
|
from src.path_confinement import confine
|
||||||
|
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
@@ -61,7 +61,6 @@ from .email_helpers import (
|
|||||||
_fetch_sender_thread_context, _pre_retrieve_context,
|
_fetch_sender_thread_context, _pre_retrieve_context,
|
||||||
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
||||||
_friendly_email_auth_error, _email_summary_failure_log_detail,
|
_friendly_email_auth_error, _email_summary_failure_log_detail,
|
||||||
_mail_connection_test_error, _mail_private_blocked, _PolicySMTP, _PolicySMTP_SSL,
|
|
||||||
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
|
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||||
SendEmailRequest, ExtractStyleRequest,
|
SendEmailRequest, ExtractStyleRequest,
|
||||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||||
@@ -1543,44 +1542,6 @@ def _envelope_recipients(*fields: str) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
_MD_LINK_URL_RE = re.compile(r"\((https?://)[^)\s]*")
|
|
||||||
|
|
||||||
|
|
||||||
def _md_links_to_html(s: str) -> str:
|
|
||||||
"""Linear ``re.sub(r"\\[([^\\]]+)\\]\\((https?://[^)\\s]+)\\)", '<a href="\\2">\\1</a>', s)``.
|
|
||||||
|
|
||||||
Every `[` before the next `]` shares that `]`, so only the first can match;
|
|
||||||
and when a URL runs into whitespace/end without `)`, every later `[` whose
|
|
||||||
`]` also lies before that stop fails the same way. Skipping those spans
|
|
||||||
keeps a `[[[` or `[a](http://x[a](http://x` flood O(n) instead of the
|
|
||||||
regex's O(n^2).
|
|
||||||
"""
|
|
||||||
out: list[str] = []
|
|
||||||
emitted = scan = 0
|
|
||||||
while True:
|
|
||||||
open_at = s.find("[", scan)
|
|
||||||
if open_at < 0:
|
|
||||||
break
|
|
||||||
close_at = s.find("]", open_at + 1)
|
|
||||||
if close_at < 0:
|
|
||||||
break
|
|
||||||
scan = close_at + 1
|
|
||||||
if close_at == open_at + 1:
|
|
||||||
continue
|
|
||||||
url = _MD_LINK_URL_RE.match(s, close_at + 1)
|
|
||||||
if url is None:
|
|
||||||
continue
|
|
||||||
end = url.end()
|
|
||||||
if end < len(s) and s[end] == ")" and end > url.end(1):
|
|
||||||
out.append(s[emitted:open_at])
|
|
||||||
out.append(f'<a href="{s[url.start(1):end]}">{s[open_at + 1:close_at]}</a>')
|
|
||||||
emitted = scan = end + 1
|
|
||||||
elif end > url.end(1):
|
|
||||||
scan = max(scan, s.rfind("]", scan, end) + 1)
|
|
||||||
out.append(s[emitted:])
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def _md_to_email_html(text: str) -> str:
|
def _md_to_email_html(text: str) -> str:
|
||||||
"""Render the compose markdown body to a SAFE HTML fragment for the email's
|
"""Render the compose markdown body to a SAFE HTML fragment for the email's
|
||||||
text/html part. Everything is HTML-escaped FIRST (so a pasted <script> /
|
text/html part. Everything is HTML-escaped FIRST (so a pasted <script> /
|
||||||
@@ -1596,7 +1557,7 @@ def _md_to_email_html(text: str) -> str:
|
|||||||
s = re.sub(r"~~([^~]+)~~", r"<del>\1</del>", s)
|
s = re.sub(r"~~([^~]+)~~", r"<del>\1</del>", s)
|
||||||
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
||||||
# links: text + http(s) url only (escape() already neutralised quotes)
|
# links: text + http(s) url only (escape() already neutralised quotes)
|
||||||
s = _md_links_to_html(s)
|
s = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r'<a href="\2">\1</a>', s)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
@@ -1815,13 +1776,60 @@ def setup_email_routes():
|
|||||||
return None
|
return None
|
||||||
return v[1]
|
return v[1]
|
||||||
|
|
||||||
|
# Stale-while-revalidate snapshot store: a last-good list response per key
|
||||||
|
# with NO TTL, so a list view can render instantly from a snapshot (or the
|
||||||
|
# durable index) and refresh in the background instead of blocking ~2-5 s on
|
||||||
|
# IMAP. Kept small and separate from the TTL cache so invalidation of the
|
||||||
|
# fresh cache never removes the fallback snapshot.
|
||||||
|
_LIST_STALE = {}
|
||||||
|
|
||||||
|
def _list_stale_get(key):
|
||||||
|
return _LIST_STALE.get(key)
|
||||||
|
|
||||||
|
def _list_stale_put(key, value):
|
||||||
|
_LIST_STALE[key] = value
|
||||||
|
if len(_LIST_STALE) > 96:
|
||||||
|
for k in list(_LIST_STALE.keys())[:-48]:
|
||||||
|
_LIST_STALE.pop(k, None)
|
||||||
|
|
||||||
|
_LIST_REFRESHING = set()
|
||||||
|
|
||||||
def _list_cache_put(key, value):
|
def _list_cache_put(key, value):
|
||||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||||
|
# Also snapshot the last-good response for stale-while-revalidate.
|
||||||
|
_list_stale_put(key, value)
|
||||||
# Cap size
|
# Cap size
|
||||||
if len(_LIST_CACHE) > 64:
|
if len(_LIST_CACHE) > 64:
|
||||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||||
_LIST_CACHE.pop(k, None)
|
_LIST_CACHE.pop(k, None)
|
||||||
|
|
||||||
|
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
||||||
|
"""Refresh a list snapshot off the event loop, at most once per key.
|
||||||
|
|
||||||
|
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
||||||
|
the TTL cache and the stale snapshot so the next list view is warm.
|
||||||
|
"""
|
||||||
|
if cache_key in _LIST_REFRESHING:
|
||||||
|
return
|
||||||
|
_LIST_REFRESHING.add(cache_key)
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
refreshed = _list_emails_sync(*sync_args)
|
||||||
|
if refreshed and not refreshed.get("error"):
|
||||||
|
_list_cache_put(cache_key, refreshed)
|
||||||
|
try:
|
||||||
|
_schedule_recent_email_warm(
|
||||||
|
refreshed.get("emails") or [], folder, account_id, owner)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # pragma: no cover - background best effort
|
||||||
|
logger.debug("email list background refresh failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
_LIST_REFRESHING.discard(cache_key)
|
||||||
|
|
||||||
|
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||||
|
|
||||||
def _folder_cache_get(account_id, owner):
|
def _folder_cache_get(account_id, owner):
|
||||||
key = (account_id or "", owner or "")
|
key = (account_id or "", owner or "")
|
||||||
v = _FOLDER_CACHE.get(key)
|
v = _FOLDER_CACHE.get(key)
|
||||||
@@ -2848,6 +2856,46 @@ def setup_email_routes():
|
|||||||
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
|
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
|
||||||
)
|
)
|
||||||
return cached
|
return cached
|
||||||
|
# Stale-while-revalidate: never block a plain list view on IMAP while
|
||||||
|
# we already hold a recent snapshot or a durable index. Serve it now
|
||||||
|
# with a `stale` marker and refresh in the background so the next view
|
||||||
|
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
||||||
|
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
||||||
|
if not from_addr and not has_attachments and not (date_from or date_to):
|
||||||
|
snapshot = _list_stale_get(ck)
|
||||||
|
if snapshot is None:
|
||||||
|
try:
|
||||||
|
indexed_emails, indexed_total, indexed_at = _email_index_list(
|
||||||
|
owner, account_id, folder, filter, limit, offset, False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
indexed_emails, indexed_total, indexed_at = [], 0, None
|
||||||
|
if indexed_total:
|
||||||
|
snapshot = {
|
||||||
|
"emails": indexed_emails,
|
||||||
|
"total": indexed_total,
|
||||||
|
"folder": folder,
|
||||||
|
"offset": offset,
|
||||||
|
"sync": {
|
||||||
|
"source": "index",
|
||||||
|
"indexed": indexed_total,
|
||||||
|
"updated_at": indexed_at,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if snapshot is not None:
|
||||||
|
payload = dict(snapshot)
|
||||||
|
payload["emails"] = list(payload.get("emails") or [])
|
||||||
|
_hide_unlinked_calendar_tags(payload["emails"])
|
||||||
|
sync_meta = dict(payload.get("sync") or {})
|
||||||
|
sync_meta["source"] = "cache_stale" if _list_stale_get(ck) is not None else "index"
|
||||||
|
sync_meta["stale"] = True
|
||||||
|
payload["sync"] = sync_meta
|
||||||
|
_schedule_list_refresh(
|
||||||
|
(folder, limit, offset, filter, account_id, from_addr,
|
||||||
|
bool(has_attachments), owner, True, date_from or "", date_to or ""),
|
||||||
|
ck, folder, account_id, owner,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
result = await _asyncio.to_thread(
|
result = await _asyncio.to_thread(
|
||||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||||
@@ -3964,7 +4012,7 @@ def setup_email_routes():
|
|||||||
_row, att = fixture_att
|
_row, att = fixture_att
|
||||||
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
||||||
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
|
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filepath = target_dir / safe_name
|
filepath = target_dir / safe_name
|
||||||
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
|
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
|
||||||
@@ -3983,7 +4031,7 @@ def setup_email_routes():
|
|||||||
msg = email_mod.message_from_bytes(raw)
|
msg = email_mod.message_from_bytes(raw)
|
||||||
|
|
||||||
# Extract to a per-email folder
|
# Extract to a per-email folder
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||||
if not filepath:
|
if not filepath:
|
||||||
return {"error": f"Attachment index {index} not found"}
|
return {"error": f"Attachment index {index} not found"}
|
||||||
@@ -4015,7 +4063,7 @@ def setup_email_routes():
|
|||||||
if not attachments:
|
if not attachments:
|
||||||
raise HTTPException(status_code=404, detail="No downloadable attachments")
|
raise HTTPException(status_code=404, detail="No downloadable attachments")
|
||||||
|
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
zip_buf = io.BytesIO()
|
zip_buf = io.BytesIO()
|
||||||
used_names: dict[str, int] = {}
|
used_names: dict[str, int] = {}
|
||||||
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
@@ -4155,7 +4203,7 @@ def setup_email_routes():
|
|||||||
att for att in _list_attachments_from_msg(msg)
|
att for att in _list_attachments_from_msg(msg)
|
||||||
if not _is_likely_signature_image_attachment(att)
|
if not _is_likely_signature_image_attachment(att)
|
||||||
]
|
]
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=acct)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
for att in attachments:
|
for att in attachments:
|
||||||
idx = att.get("index")
|
idx = att.get("index")
|
||||||
if idx is None:
|
if idx is None:
|
||||||
@@ -4220,7 +4268,7 @@ def setup_email_routes():
|
|||||||
continue
|
continue
|
||||||
if not ct.lower().startswith("image/"):
|
if not ct.lower().startswith("image/"):
|
||||||
raise HTTPException(status_code=415, detail="Content-ID is not an image")
|
raise HTTPException(status_code=415, detail="Content-ID is not an image")
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
filepath = _extract_attachment_to_disk(msg, idx, target_dir)
|
filepath = _extract_attachment_to_disk(msg, idx, target_dir)
|
||||||
if not filepath:
|
if not filepath:
|
||||||
raise HTTPException(status_code=404, detail="Inline image not found")
|
raise HTTPException(status_code=404, detail="Inline image not found")
|
||||||
@@ -4258,7 +4306,7 @@ def setup_email_routes():
|
|||||||
raw = msg_data[0][1]
|
raw = msg_data[0][1]
|
||||||
msg = email_mod.message_from_bytes(raw)
|
msg = email_mod.message_from_bytes(raw)
|
||||||
|
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||||
if not filepath:
|
if not filepath:
|
||||||
return {"error": f"Attachment index {index} not found"}
|
return {"error": f"Attachment index {index} not found"}
|
||||||
@@ -4586,9 +4634,7 @@ def setup_email_routes():
|
|||||||
try:
|
try:
|
||||||
content = filepath.read_text(encoding="utf-8", errors="replace")
|
content = filepath.read_text(encoding="utf-8", errors="replace")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# OSError text includes the server-side extraction path.
|
return {"error": f"Failed to read text file: {e}", "filename": base}
|
||||||
logger.warning("Failed to read text attachment %s: %s", base, e)
|
|
||||||
return {"error": "Failed to read text file", "filename": base}
|
|
||||||
doc_id = _create_markdown_doc(content, "Imported from email attachment")
|
doc_id = _create_markdown_doc(content, "Imported from email attachment")
|
||||||
return {"doc_id": doc_id, "filename": filepath.name}
|
return {"doc_id": doc_id, "filename": filepath.name}
|
||||||
|
|
||||||
@@ -4605,7 +4651,7 @@ def setup_email_routes():
|
|||||||
_row, att = fixture_att
|
_row, att = fixture_att
|
||||||
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
||||||
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
|
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filepath = target_dir / safe_name
|
filepath = target_dir / safe_name
|
||||||
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
|
filepath.write_bytes(str(att.get("content") or "").encode("utf-8"))
|
||||||
@@ -4619,7 +4665,7 @@ def setup_email_routes():
|
|||||||
raw = msg_data[0][1]
|
raw = msg_data[0][1]
|
||||||
msg = email_mod.message_from_bytes(raw)
|
msg = email_mod.message_from_bytes(raw)
|
||||||
|
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||||
if not filepath:
|
if not filepath:
|
||||||
return {"error": f"Attachment index {index} not found"}
|
return {"error": f"Attachment index {index} not found"}
|
||||||
@@ -5273,7 +5319,7 @@ def setup_email_routes():
|
|||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
raw = msg_data[0][1]
|
raw = msg_data[0][1]
|
||||||
msg = email_mod.message_from_bytes(raw)
|
msg = email_mod.message_from_bytes(raw)
|
||||||
target_dir = attachment_extract_dir(folder, uid, owner=owner, account_id=account_id)
|
target_dir = attachment_extract_dir(folder, uid)
|
||||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||||
if not filepath:
|
if not filepath:
|
||||||
return {"success": False, "error": f"Attachment index {index} not found"}
|
return {"success": False, "error": f"Attachment index {index} not found"}
|
||||||
@@ -6290,7 +6336,7 @@ def setup_email_routes():
|
|||||||
await queue.put({'type': 'result', **result})
|
await queue.put({'type': 'result', **result})
|
||||||
task = asyncio.create_task(run())
|
task = asyncio.create_task(run())
|
||||||
try:
|
try:
|
||||||
while True:
|
while not _shutdown.is_shutting_down():
|
||||||
item = await queue.get()
|
item = await queue.get()
|
||||||
yield 'data: ' + json.dumps(item) + '\n\n'
|
yield 'data: ' + json.dumps(item) + '\n\n'
|
||||||
if item.get('type') == 'result':
|
if item.get('type') == 'result':
|
||||||
@@ -6980,158 +7026,6 @@ def setup_email_routes():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def _test_mail_connections(body: dict, owner: str) -> dict:
|
|
||||||
"""Blocking IMAP/SMTP connection test for /accounts/test.
|
|
||||||
|
|
||||||
Runs in the threadpool: each probe can block for its full socket
|
|
||||||
timeout, which on the event loop stalled every other request. The
|
|
||||||
connections go through the outbound mail address policy for `owner`
|
|
||||||
(the user who chose the hosts)."""
|
|
||||||
imap_result = {"ok": False}
|
|
||||||
smtp_result = None
|
|
||||||
|
|
||||||
imap_host = (body.get("imap_host") or "").strip()
|
|
||||||
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
|
|
||||||
imap_user = (body.get("imap_user") or "").strip()
|
|
||||||
imap_pass = body.get("imap_password") or ""
|
|
||||||
imap_starttls = bool(body.get("imap_starttls"))
|
|
||||||
oauth_provider = body.get("oauth_provider") or ""
|
|
||||||
|
|
||||||
google_token = None
|
|
||||||
google_token_loaded = False
|
|
||||||
google_ssl_context = (
|
|
||||||
ssl.create_default_context()
|
|
||||||
if oauth_provider == "google"
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
def _google_token():
|
|
||||||
nonlocal google_token, google_token_loaded
|
|
||||||
if not google_token_loaded:
|
|
||||||
google_token = _get_valid_google_token(body.get("account_id"), body)
|
|
||||||
google_token_loaded = True
|
|
||||||
if not google_token:
|
|
||||||
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
|
|
||||||
return google_token
|
|
||||||
|
|
||||||
if imap_port_err:
|
|
||||||
imap_result = {"ok": False, "error": imap_port_err}
|
|
||||||
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
|
|
||||||
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
|
|
||||||
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
|
|
||||||
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
|
|
||||||
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
|
|
||||||
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
|
|
||||||
else:
|
|
||||||
# Connection mode resolution:
|
|
||||||
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
|
|
||||||
# STARTTLS off + port 993 → IMAP4_SSL (implicit SSL, "IMAPS")
|
|
||||||
# STARTTLS off + any other port → plain IMAP4 (no encryption)
|
|
||||||
# Without the last branch, local servers exposed on a non-993
|
|
||||||
# port (Dovecot on 31143, etc.) would always fail the SSL
|
|
||||||
# handshake because they're not actually wrapped in TLS.
|
|
||||||
try:
|
|
||||||
imap_kwargs = {
|
|
||||||
"starttls": imap_starttls,
|
|
||||||
"timeout": _IMAP_TIMEOUT_SECONDS,
|
|
||||||
"owner": owner,
|
|
||||||
}
|
|
||||||
if google_ssl_context:
|
|
||||||
imap_kwargs["ssl_context"] = google_ssl_context
|
|
||||||
conn = _open_imap_connection(
|
|
||||||
imap_host,
|
|
||||||
imap_port,
|
|
||||||
**imap_kwargs,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if oauth_provider == "google":
|
|
||||||
token = _google_token()
|
|
||||||
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
|
|
||||||
else:
|
|
||||||
conn.login(imap_user, imap_pass)
|
|
||||||
imap_result = {"ok": True}
|
|
||||||
finally:
|
|
||||||
try: conn.logout()
|
|
||||||
except Exception: pass
|
|
||||||
except Exception as e:
|
|
||||||
imap_result = {"ok": False, "error": _mail_connection_test_error("IMAP", imap_host, e)}
|
|
||||||
|
|
||||||
smtp_host = (body.get("smtp_host") or "").strip()
|
|
||||||
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
|
|
||||||
if smtp_host and smtp_port_err:
|
|
||||||
smtp_result = {"ok": False, "error": smtp_port_err}
|
|
||||||
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
|
|
||||||
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
|
|
||||||
elif (
|
|
||||||
oauth_provider == "google"
|
|
||||||
and smtp_host
|
|
||||||
and not _google_oauth_smtp_transport_allowed(
|
|
||||||
smtp_port,
|
|
||||||
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
|
|
||||||
)
|
|
||||||
):
|
|
||||||
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
|
|
||||||
elif smtp_host:
|
|
||||||
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
|
|
||||||
smtp_user = (body.get("smtp_user") or imap_user).strip()
|
|
||||||
smtp_pass = body.get("smtp_password") or imap_pass
|
|
||||||
smtp = None
|
|
||||||
try:
|
|
||||||
if smtp_security == "ssl":
|
|
||||||
smtp_kwargs = (
|
|
||||||
{"context": google_ssl_context}
|
|
||||||
if google_ssl_context
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
smtp = _PolicySMTP_SSL(
|
|
||||||
smtp_host,
|
|
||||||
smtp_port,
|
|
||||||
timeout=10,
|
|
||||||
block_private=_mail_private_blocked(owner),
|
|
||||||
**smtp_kwargs,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
smtp = _PolicySMTP(smtp_host, smtp_port, timeout=10, block_private=_mail_private_blocked(owner))
|
|
||||||
if smtp_security == "starttls":
|
|
||||||
try:
|
|
||||||
if google_ssl_context:
|
|
||||||
smtp.starttls(context=google_ssl_context)
|
|
||||||
else:
|
|
||||||
smtp.starttls()
|
|
||||||
except Exception:
|
|
||||||
# STARTTLS failed before the auth cleanup block.
|
|
||||||
# Close the still-open plaintext socket explicitly.
|
|
||||||
try:
|
|
||||||
smtp.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
smtp = None
|
|
||||||
raise
|
|
||||||
if oauth_provider == "google":
|
|
||||||
token = _google_token()
|
|
||||||
smtp.ehlo()
|
|
||||||
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
|
|
||||||
else:
|
|
||||||
smtp.login(smtp_user, smtp_pass)
|
|
||||||
smtp_result = {"ok": True}
|
|
||||||
except Exception as e:
|
|
||||||
smtp_result = {"ok": False, "error": _mail_connection_test_error("SMTP", smtp_host, e)}
|
|
||||||
finally:
|
|
||||||
if smtp is not None:
|
|
||||||
try:
|
|
||||||
smtp.quit()
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
smtp.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
|
|
||||||
"imap": imap_result,
|
|
||||||
"smtp": smtp_result,
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/accounts/test")
|
@router.post("/accounts/test")
|
||||||
async def test_account_config(req: Request, owner: str = Depends(require_user)):
|
async def test_account_config(req: Request, owner: str = Depends(require_user)):
|
||||||
"""Try to actually connect to the provided IMAP (and optionally SMTP)
|
"""Try to actually connect to the provided IMAP (and optionally SMTP)
|
||||||
@@ -7195,7 +7089,148 @@ def setup_email_routes():
|
|||||||
# inline test payloads select the OAuth branch or supply token data.
|
# inline test payloads select the OAuth branch or supply token data.
|
||||||
body = {key: value for key, value in body.items() if key not in _SERVER_OWNED_OAUTH_FIELDS}
|
body = {key: value for key, value in body.items() if key not in _SERVER_OWNED_OAUTH_FIELDS}
|
||||||
|
|
||||||
return await run_in_threadpool(_test_mail_connections, body, owner)
|
imap_result = {"ok": False}
|
||||||
|
smtp_result = None
|
||||||
|
|
||||||
|
imap_host = (body.get("imap_host") or "").strip()
|
||||||
|
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
|
||||||
|
imap_user = (body.get("imap_user") or "").strip()
|
||||||
|
imap_pass = body.get("imap_password") or ""
|
||||||
|
imap_starttls = bool(body.get("imap_starttls"))
|
||||||
|
oauth_provider = body.get("oauth_provider") or ""
|
||||||
|
|
||||||
|
google_token = None
|
||||||
|
google_token_loaded = False
|
||||||
|
google_ssl_context = (
|
||||||
|
ssl.create_default_context()
|
||||||
|
if oauth_provider == "google"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _google_token():
|
||||||
|
nonlocal google_token, google_token_loaded
|
||||||
|
if not google_token_loaded:
|
||||||
|
google_token = _get_valid_google_token(body.get("account_id"), body)
|
||||||
|
google_token_loaded = True
|
||||||
|
if not google_token:
|
||||||
|
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
|
||||||
|
return google_token
|
||||||
|
|
||||||
|
if imap_port_err:
|
||||||
|
imap_result = {"ok": False, "error": imap_port_err}
|
||||||
|
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
|
||||||
|
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
|
||||||
|
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
|
||||||
|
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
|
||||||
|
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
|
||||||
|
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
|
||||||
|
else:
|
||||||
|
# Connection mode resolution:
|
||||||
|
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
|
||||||
|
# STARTTLS off + port 993 → IMAP4_SSL (implicit SSL, "IMAPS")
|
||||||
|
# STARTTLS off + any other port → plain IMAP4 (no encryption)
|
||||||
|
# Without the last branch, local servers exposed on a non-993
|
||||||
|
# port (Dovecot on 31143, etc.) would always fail the SSL
|
||||||
|
# handshake because they're not actually wrapped in TLS.
|
||||||
|
try:
|
||||||
|
imap_kwargs = {
|
||||||
|
"starttls": imap_starttls,
|
||||||
|
"timeout": _IMAP_TIMEOUT_SECONDS,
|
||||||
|
}
|
||||||
|
if google_ssl_context:
|
||||||
|
imap_kwargs["ssl_context"] = google_ssl_context
|
||||||
|
conn = _open_imap_connection(
|
||||||
|
imap_host,
|
||||||
|
imap_port,
|
||||||
|
**imap_kwargs,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if oauth_provider == "google":
|
||||||
|
token = _google_token()
|
||||||
|
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
|
||||||
|
else:
|
||||||
|
conn.login(imap_user, imap_pass)
|
||||||
|
imap_result = {"ok": True}
|
||||||
|
finally:
|
||||||
|
try: conn.logout()
|
||||||
|
except Exception: pass
|
||||||
|
except Exception as e:
|
||||||
|
imap_result = {"ok": False, "error": _friendly_email_auth_error("IMAP", imap_host, e)}
|
||||||
|
|
||||||
|
smtp_host = (body.get("smtp_host") or "").strip()
|
||||||
|
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
|
||||||
|
if smtp_host and smtp_port_err:
|
||||||
|
smtp_result = {"ok": False, "error": smtp_port_err}
|
||||||
|
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
|
||||||
|
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
|
||||||
|
elif (
|
||||||
|
oauth_provider == "google"
|
||||||
|
and smtp_host
|
||||||
|
and not _google_oauth_smtp_transport_allowed(
|
||||||
|
smtp_port,
|
||||||
|
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
|
||||||
|
elif smtp_host:
|
||||||
|
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
|
||||||
|
smtp_user = (body.get("smtp_user") or imap_user).strip()
|
||||||
|
smtp_pass = body.get("smtp_password") or imap_pass
|
||||||
|
smtp = None
|
||||||
|
try:
|
||||||
|
if smtp_security == "ssl":
|
||||||
|
smtp_kwargs = (
|
||||||
|
{"context": google_ssl_context}
|
||||||
|
if google_ssl_context
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
smtp = smtplib.SMTP_SSL(
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
timeout=10,
|
||||||
|
**smtp_kwargs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
|
||||||
|
if smtp_security == "starttls":
|
||||||
|
try:
|
||||||
|
if google_ssl_context:
|
||||||
|
smtp.starttls(context=google_ssl_context)
|
||||||
|
else:
|
||||||
|
smtp.starttls()
|
||||||
|
except Exception:
|
||||||
|
# STARTTLS failed before the auth cleanup block.
|
||||||
|
# Close the still-open plaintext socket explicitly.
|
||||||
|
try:
|
||||||
|
smtp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
smtp = None
|
||||||
|
raise
|
||||||
|
if oauth_provider == "google":
|
||||||
|
token = _google_token()
|
||||||
|
smtp.ehlo()
|
||||||
|
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
|
||||||
|
else:
|
||||||
|
smtp.login(smtp_user, smtp_pass)
|
||||||
|
smtp_result = {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
smtp_result = {"ok": False, "error": _friendly_email_auth_error("SMTP", smtp_host, e)}
|
||||||
|
finally:
|
||||||
|
if smtp is not None:
|
||||||
|
try:
|
||||||
|
smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
smtp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
|
||||||
|
"imap": imap_result,
|
||||||
|
"smtp": smtp_result,
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/accounts/{account_id}/set-default")
|
@router.post("/accounts/{account_id}/set-default")
|
||||||
async def set_default_account(account_id: str, owner: str = Depends(require_user)):
|
async def set_default_account(account_id: str, owner: str = Depends(require_user)):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+4
-10
@@ -5,9 +5,8 @@ import shlex
|
|||||||
import subprocess
|
import subprocess
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from core.middleware import require_admin
|
|
||||||
from core.platform_compat import run_ssh_command
|
from core.platform_compat import run_ssh_command
|
||||||
from routes._validators import validate_remote_host, validate_ssh_port
|
from routes._validators import validate_remote_host, validate_ssh_port
|
||||||
|
|
||||||
@@ -199,11 +198,7 @@ def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") ->
|
|||||||
|
|
||||||
|
|
||||||
def setup_hwfit_routes():
|
def setup_hwfit_routes():
|
||||||
# Admin-gated like the Cookbook routes this backs (/api/cookbook/gpus does
|
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
|
||||||
# the same SSH host probing): `host`/`ssh_port` make the server SSH out with
|
|
||||||
# its own key to any syntactically valid host, and `model_path` probes the
|
|
||||||
# host filesystem. Single-user mode and internal tool calls still pass.
|
|
||||||
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"], dependencies=[Depends(require_admin)])
|
|
||||||
|
|
||||||
@router.get("/system")
|
@router.get("/system")
|
||||||
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False):
|
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False):
|
||||||
@@ -223,7 +218,7 @@ def setup_hwfit_routes():
|
|||||||
fresh=true bypasses the hardware-detection cache."""
|
fresh=true bypasses the hardware-detection cache."""
|
||||||
from services.hwfit.hardware import detect_system
|
from services.hwfit.hardware import detect_system
|
||||||
from services.hwfit.fit import rank_models
|
from services.hwfit.fit import rank_models
|
||||||
from services.hwfit.models import get_models, refresh_dynamic_catalogs
|
from services.hwfit.models import get_models, model_catalog_path, refresh_dynamic_catalogs
|
||||||
host, ssh_port = _validate_detection_target(host, ssh_port)
|
host, ssh_port = _validate_detection_target(host, ssh_port)
|
||||||
system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh))
|
system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh))
|
||||||
if system.get("error"):
|
if system.get("error"):
|
||||||
@@ -238,8 +233,7 @@ def setup_hwfit_routes():
|
|||||||
return {
|
return {
|
||||||
"system": system,
|
"system": system,
|
||||||
"models": [],
|
"models": [],
|
||||||
"error": "Model catalog is empty. Use Rescan in Cookbook while online to populate runtime model data; offline recommendations require a previously populated cache.",
|
"error": f"Model catalog missing or empty: {model_catalog_path()}",
|
||||||
**({"catalog_refresh": catalog_refresh} if catalog_refresh is not None else {}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ignore_detected_gpu:
|
if ignore_detected_gpu:
|
||||||
|
|||||||
+22
-7
@@ -472,7 +472,7 @@ def _truthy(value: str | None) -> bool:
|
|||||||
|
|
||||||
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
||||||
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
||||||
_MODEL_TOOL_MODES = {"none", "compact", "full"}
|
_MODEL_TOOL_MODES = {"none", "compact", "full", "spec"}
|
||||||
_MODEL_TOOL_MODE_ALIASES = {
|
_MODEL_TOOL_MODE_ALIASES = {
|
||||||
"regular": "full",
|
"regular": "full",
|
||||||
"odysseus_compact": "compact",
|
"odysseus_compact": "compact",
|
||||||
@@ -552,10 +552,10 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float:
|
|||||||
val = 0
|
val = 0
|
||||||
if val > 0:
|
if val > 0:
|
||||||
return float(max(1, min(60, val)))
|
return float(max(1, min(60, val)))
|
||||||
# llama.cpp and other local OpenAI-compatible servers can block briefly
|
# Keep the background refresh short so a single dead endpoint cannot stall
|
||||||
# while warming/loading. A 2s local timeout makes working endpoints flicker
|
# the model picker. A timed-out refresh never clears a non-empty model
|
||||||
# offline before /v1/models is ready.
|
# cache, so a briefly-warming local server is simply retried next round.
|
||||||
return 10.0 if category == "local" else 2.0
|
return 2.0
|
||||||
|
|
||||||
|
|
||||||
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
||||||
@@ -1510,6 +1510,9 @@ def setup_model_routes(model_discovery):
|
|||||||
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
|
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
|
||||||
_REFRESH_FAILURE_BASE = 300.0
|
_REFRESH_FAILURE_BASE = 300.0
|
||||||
_REFRESH_FAILURE_MAX = 3600.0
|
_REFRESH_FAILURE_MAX = 3600.0
|
||||||
|
# Even a user-triggered refresh must not re-probe an endpoint that just
|
||||||
|
# failed/timed out on every click; keep a short cooldown.
|
||||||
|
_REFRESH_FAILURE_MIN = 60.0
|
||||||
|
|
||||||
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
|
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
|
||||||
# Session-backed endpoints carry no static key; include their auth id
|
# Session-backed endpoints carry no static key; include their auth id
|
||||||
@@ -1557,14 +1560,20 @@ def setup_model_routes(model_discovery):
|
|||||||
if mode in ("manual", "disabled") and not force:
|
if mode in ("manual", "disabled") and not force:
|
||||||
return False, info
|
return False, info
|
||||||
fails = int(state.get("fail_count") or 0)
|
fails = int(state.get("fail_count") or 0)
|
||||||
if fails and not force:
|
if fails:
|
||||||
last_failure = float(state.get("last_failure") or 0.0)
|
last_failure = float(state.get("last_failure") or 0.0)
|
||||||
empty_local = (
|
empty_local = (
|
||||||
not cached
|
not cached
|
||||||
and category == "local"
|
and category == "local"
|
||||||
and str(getattr(ep, "id", "") or "").startswith("local-")
|
and str(getattr(ep, "id", "") or "").startswith("local-")
|
||||||
)
|
)
|
||||||
if now - last_failure < _failure_delay(fails, empty_local=empty_local):
|
delay = _failure_delay(fails, empty_local=empty_local)
|
||||||
|
if force:
|
||||||
|
# A user-triggered refresh gets a short cooldown instead of the
|
||||||
|
# long background backoff, but still skips a just-failed
|
||||||
|
# endpoint so repeated clicks cannot stall the picker.
|
||||||
|
delay = min(delay, _REFRESH_FAILURE_MIN)
|
||||||
|
if now - last_failure < delay:
|
||||||
return False, info
|
return False, info
|
||||||
if cached and not force:
|
if cached and not force:
|
||||||
interval = _endpoint_refresh_interval(ep, category)
|
interval = _endpoint_refresh_interval(ep, category)
|
||||||
@@ -3055,4 +3064,10 @@ def setup_model_routes(model_discovery):
|
|||||||
|
|
||||||
router._should_refresh_endpoint = _should_refresh_endpoint
|
router._should_refresh_endpoint = _should_refresh_endpoint
|
||||||
router._search_endpoint_catalog = search_endpoint_catalog
|
router._search_endpoint_catalog = search_endpoint_catalog
|
||||||
|
# Exposed for tests: the in-memory backoff state, its key function, and the
|
||||||
|
# background refresh driver (probes endpoints in parallel with the short
|
||||||
|
# per-endpoint timeout).
|
||||||
|
router._refresh_state = _refresh_state
|
||||||
|
router._refresh_key = _refresh_key
|
||||||
|
router._refresh_caches_bg = _refresh_caches_bg
|
||||||
return router
|
return router
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Specs DocumentMap
|
# Specs DocumentMap
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
This folder is the compact implementation-truth map for humans and coding agents working on Odysseus. Read this file first, then open only the subsystem specs that match the work.
|
This folder is the compact implementation-truth map for humans and coding agents working on Odysseus. Read this file first, then open only the subsystem specs that match the work.
|
||||||
|
|
||||||
@@ -56,14 +56,14 @@ project documentation.
|
|||||||
- [context-building.md](context-building.md): URL/search/RAG/memory/skills/YouTube/email/tool-output context, untrusted wrapping, unavailable context, intent boundaries.
|
- [context-building.md](context-building.md): URL/search/RAG/memory/skills/YouTube/email/tool-output context, untrusted wrapping, unavailable context, intent boundaries.
|
||||||
- [search.md](search.md): web search providers, ranking, cache/analytics, URL fetch/content extraction, `src.search`/`services.search` split.
|
- [search.md](search.md): web search providers, ranking, cache/analytics, URL fetch/content extraction, `src.search`/`services.search` split.
|
||||||
- [documents-rag-uploads.md](documents-rag-uploads.md): uploads, documents, PDF/form handling, personal docs, RAG/vector stores.
|
- [documents-rag-uploads.md](documents-rag-uploads.md): uploads, documents, PDF/form handling, personal docs, RAG/vector stores.
|
||||||
- [memory-skills.md](memory-skills.md): memory storage, semantic memory, skill storage/extraction and service-owned audit/evaluation, owner isolation.
|
- [memory-skills.md](memory-skills.md): memory storage, semantic memory, skill extraction/formatting, owner isolation.
|
||||||
- [research.md](research.md): deep research jobs, synthesis, sources, research library, research UI panel.
|
- [research.md](research.md): deep research jobs, synthesis, sources, research library, research UI panel.
|
||||||
- [calendar-tasks-notes.md](calendar-tasks-notes.md): CalDAV calendars, scheduled tasks, reminders, assistant runs, Notes feature state and lifecycle.
|
- [calendar-tasks-notes.md](calendar-tasks-notes.md): CalDAV calendars, scheduled tasks, reminders, assistant runs, notes/todos.
|
||||||
- [email-contacts.md](email-contacts.md): IMAP/SMTP email, email library, scheduled mail, contacts/CardDAV.
|
- [email-contacts.md](email-contacts.md): IMAP/SMTP email, email library, scheduled mail, contacts/CardDAV.
|
||||||
- [gallery-editor-media.md](gallery-editor-media.md): gallery, generated media, image editor drafts, signatures, emoji/font helpers.
|
- [gallery-editor-media.md](gallery-editor-media.md): gallery, generated media, image editor drafts, signatures, emoji/font helpers.
|
||||||
- [cookbook-hwfit.md](cookbook-hwfit.md): model downloads, local/remote model serving, hardware detection, fit ranking.
|
- [cookbook-hwfit.md](cookbook-hwfit.md): model downloads, local/remote model serving, hardware detection, fit ranking.
|
||||||
- [speech.md](speech.md): STT and TTS services, routes, settings, optional dependencies.
|
- [speech.md](speech.md): STT and TTS services, routes, settings, optional dependencies.
|
||||||
- [frontend.md](frontend.md): static native-ESM SPA, maintained Notes source/deployment graph, module loading, UI conventions and shared ownership.
|
- [frontend.md](frontend.md): static SPA, module loading, UI conventions, major JS areas, no-build frontend shape.
|
||||||
- [integrations.md](integrations.md): Codex/Claude scoped APIs, companion pairing, webhooks, external agent access.
|
- [integrations.md](integrations.md): Codex/Claude scoped APIs, companion pairing, webhooks, external agent access.
|
||||||
- [shell-mcp.md](shell-mcp.md): shell execution, background jobs, MCP manager, built-in MCP servers.
|
- [shell-mcp.md](shell-mcp.md): shell execution, background jobs, MCP manager, built-in MCP servers.
|
||||||
- [settings-admin.md](settings-admin.md): settings, preferences, presets, backup/import/export, diagnostics, admin wipe.
|
- [settings-admin.md](settings-admin.md): settings, preferences, presets, backup/import/export, diagnostics, admin wipe.
|
||||||
|
|||||||
+20
-76
@@ -1,14 +1,12 @@
|
|||||||
# Agent Tools
|
# Agent Tools
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This spec covers agent/tool behavior in:
|
This spec covers agent/tool behavior in:
|
||||||
|
|
||||||
- `src/agent_loop.py`, `src/clean_agent_preview.py`, `src/turn_contract.py`, `src/agent_evidence.py`, and `src/agent_trace.py`;
|
- `src/agent_loop.py`;
|
||||||
- `src/agent_runtime/` authority, resource binding, action journal, durable effects, completion, and context resolution modules;
|
|
||||||
- `src/containment.py`, `src/process_ownership.py`, `src/process_lifecycle.py`, and `src/browser_identity.py`;
|
|
||||||
- `src/llm_core.py`;
|
- `src/llm_core.py`;
|
||||||
- `src/tool_schemas.py`;
|
- `src/tool_schemas.py`;
|
||||||
- `src/tool_execution.py`;
|
- `src/tool_execution.py`;
|
||||||
@@ -19,7 +17,8 @@ This spec covers agent/tool behavior in:
|
|||||||
- `src/tool_capabilities.py`;
|
- `src/tool_capabilities.py`;
|
||||||
- `src/tool_approval_scopes.py`;
|
- `src/tool_approval_scopes.py`;
|
||||||
- `src/tool_approvals.py`;
|
- `src/tool_approvals.py`;
|
||||||
- `src/attachment_refs.py` and shared upload lifecycle helpers in `src/upload_handler.py` / `src/tool_utils.py`;
|
- `src/attachment_refs.py` and shared upload lifecycle helpers in
|
||||||
|
`src/upload_handler.py` / `src/tool_utils.py`;
|
||||||
- `src/tool_implementations.py`;
|
- `src/tool_implementations.py`;
|
||||||
- `src/tools/*.py`;
|
- `src/tools/*.py`;
|
||||||
- `src/builtin_actions.py`;
|
- `src/builtin_actions.py`;
|
||||||
@@ -42,7 +41,7 @@ This spec covers agent/tool behavior in:
|
|||||||
|
|
||||||
`src.llm_core` owns provider payloads, native tool-schema emission, and provider stream parsing. `agent_loop` consumes normalized tool-call events and decides whether and how to execute them.
|
`src.llm_core` owns provider payloads, native tool-schema emission, and provider stream parsing. `agent_loop` consumes normalized tool-call events and decides whether and how to execute them.
|
||||||
|
|
||||||
The legacy loop and the opt-in compact preview share the execution boundary. Agent mode enters through chat routes, including auto-escalation from intent helpers, detached `agent_runs` streaming, resume/stop behavior, and frontend tool-event rendering.
|
Agent mode enters through chat routes, including auto-escalation from intent helpers, detached `agent_runs` streaming, resume/stop behavior, and frontend tool-event rendering.
|
||||||
|
|
||||||
Guide-only/no-tools turns are runtime policy, not prompt advice. `src.tool_policy` detects strong latest-turn directives such as guide-only mode, no-tools mode, and explicit requests not to use tools; it builds a `ToolPolicy` that hides schemas, disables known native tools, disables MCP for that turn, skips tool retrieval, suppresses local/workspace context injection, blocks document streaming/teacher escalation, and gives `tool_execution` a final execution backstop.
|
Guide-only/no-tools turns are runtime policy, not prompt advice. `src.tool_policy` detects strong latest-turn directives such as guide-only mode, no-tools mode, and explicit requests not to use tools; it builds a `ToolPolicy` that hides schemas, disables known native tools, disables MCP for that turn, skips tool retrieval, suppresses local/workspace context injection, blocks document streaming/teacher escalation, and gives `tool_execution` a final execution backstop.
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ Tool registration is split:
|
|||||||
|
|
||||||
When adding, removing, or renaming a tool, update the registry chain, execution dispatch, retrieval text, prompt wording, disabled-tool UI, and tests together.
|
When adding, removing, or renaming a tool, update the registry chain, execution dispatch, retrieval text, prompt wording, disabled-tool UI, and tests together.
|
||||||
|
|
||||||
`src.tool_index.ALWAYS_AVAILABLE` is the retrieval catalog for high-frequency tools such as shell/python, web search/fetch, read/write/edit-file, code-nav, `manage_memory`, `ask_user`, `update_plan`, selected Cookbook serve controls, and `app_api`. Compact prompt/schema assembly selects policy-authorized intent-, skill-, and retrieval-relevant tools; the full-schema surface offers all policy-authorized built-ins and MCP schemas. Selection is not authority.
|
`src.tool_index.ALWAYS_AVAILABLE` is the retrieval catalog for high-frequency tools such as shell/python, web search/fetch, read/write/edit-file, code-nav, `manage_memory`, `ask_user`, `update_plan`, selected Cookbook serve controls, and `app_api`. Current prompt/schema assembly preserves only selected base tools unconditionally, then adds intent-, skill-, and retrieval-relevant tools so unrelated schemas do not flood small contexts.
|
||||||
|
|
||||||
## Tool Retrieval And Execution
|
## Tool Retrieval And Execution
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@ Tool retrieval has domain-specific hooks beyond generic similarity: contact quer
|
|||||||
|
|
||||||
Interaction/session/model helper tools are native first-class tools, not prompt-only conventions. `ask_user` and `update_plan` live in `src.agent_tools.interaction_tools`, model delegation/listing helpers live in `model_interaction_tools`, session creation/list/send/manage helpers live in `session_tools`, and `manage_bg_jobs` lives in `bg_job_tools`.
|
Interaction/session/model helper tools are native first-class tools, not prompt-only conventions. `ask_user` and `update_plan` live in `src.agent_tools.interaction_tools`, model delegation/listing helpers live in `model_interaction_tools`, session creation/list/send/manage helpers live in `session_tools`, and `manage_bg_jobs` lives in `bg_job_tools`.
|
||||||
|
|
||||||
Prompted-tool parsing includes recovery paths for local/provider text leaks: bare JSON after a web-tool mention, OpenAI-style raw `{"function": ...}` payloads, StepFun/Gemma/DSML markup, Hermes/Qwen JSON bodies nested inside `tool_call` wrappers, and `<function_model><function_call>...</function_call><parameters>...</parameters></function_model>` wrappers from local MLX/Exo models. The Qwen bare end marker requires its pipe delimiter so ordinary text cannot terminate a tool block. Non-dict JSON arguments are rejected back to empty args instead of crashing the turn and common `tex` typos normalize to `text`. Shared finite delimiter scanning lives in `src/text_scanning.py`; supported wrapper recovery and bounded scans are not a general linear-time guarantee for all parsing. Executed raw tool JSON is stripped from assistant text afterward; this is still not a general-purpose JSON-command parser.
|
Prompted-tool parsing includes recovery paths for local/provider text leaks: bare JSON after a web-tool mention, OpenAI-style raw `{"function": ...}` payloads, StepFun/Gemma/DSML markup, Hermes/Qwen JSON bodies nested inside `tool_call` wrappers, and `<function_model><function_call>...</function_call><parameters>...</parameters></function_model>` wrappers from local MLX/Exo models. The Qwen bare end marker requires its pipe delimiter so ordinary text cannot terminate a tool block. Non-dict JSON arguments are rejected back to empty args instead of crashing the turn, common `tex` typos normalize to `text`, and delimiter scans are forward-only so unterminated tool markup cannot drive quadratic rescans. Executed raw tool JSON is stripped from assistant text afterward; this is still not a general-purpose JSON-command parser.
|
||||||
|
|
||||||
Current call sites include:
|
Current call sites include:
|
||||||
|
|
||||||
@@ -109,7 +108,9 @@ Approval replay injects the sealed first tool result before the resumed model ro
|
|||||||
- `src.tool_capabilities` classifies each tool's effects and result integrity. Once external/workspace-untrusted content becomes model-visible, the request/session security context permits only explicitly low-impact tools without interruption and requires exact approval for high-impact, unknown, and arbitrary MCP calls.
|
- `src.tool_capabilities` classifies each tool's effects and result integrity. Once external/workspace-untrusted content becomes model-visible, the request/session security context permits only explicitly low-impact tools without interruption and requires exact approval for high-impact, unknown, and arbitrary MCP calls.
|
||||||
- `src.tool_approvals` seals an opaque, expiring exact first action plus server-only selected tools and continuation query to owner, session, origin run, tool content, workspace, capability snapshot, and—when relevant—document id/version/content digest. Chat choices grant the resumed task or the same chat session; both consume the exact first action, task scope bypasses the gate only during that resumed run, and chat scope is reconstructed only from a resolved card bound to the exact session id. The browser never receives selected tools/query and submits only task/chat/deny. Non-chat callers retain single-action behavior; new normal turns and superseding actions retire unresolved approvals without clearing taint.
|
- `src.tool_approvals` seals an opaque, expiring exact first action plus server-only selected tools and continuation query to owner, session, origin run, tool content, workspace, capability snapshot, and—when relevant—document id/version/content digest. Chat choices grant the resumed task or the same chat session; both consume the exact first action, task scope bypasses the gate only during that resumed run, and chat scope is reconstructed only from a resolved card bound to the exact session id. The browser never receives selected tools/query and submits only task/chat/deny. Non-chat callers retain single-action behavior; new normal turns and superseding actions retire unresolved approvals without clearing taint.
|
||||||
- Tool results that expose remote or stored untrusted content arm the gate even when their tool status is failed. Content-free failures and server-generated policy/approval placeholders do not. Native/provider tool messages and fenced results carry model-visible untrusted metadata/wrapping instead of relying on prompt wording alone.
|
- Tool results that expose remote or stored untrusted content arm the gate even when their tool status is failed. Content-free failures and server-generated policy/approval placeholders do not. Native/provider tool messages and fenced results carry model-visible untrusted metadata/wrapping instead of relying on prompt wording alone.
|
||||||
- Attachment-bearing document, note, and calendar tools owner-reserve internal upload references before durable writes and fail without mutation when the referenced upload is unavailable.
|
- Attachment-bearing document, note, and calendar tools owner-reserve internal
|
||||||
|
upload references before durable writes and fail without mutation when the
|
||||||
|
referenced upload is unavailable.
|
||||||
- Guide-only/no-tools mode blocks tools before prompt assembly, before execution, and in chat preprocessing paths that would otherwise fetch context or start tool-backed research.
|
- Guide-only/no-tools mode blocks tools before prompt assembly, before execution, and in chat preprocessing paths that would otherwise fetch context or start tool-backed research.
|
||||||
- Plan mode is policy, not prompt advice: mutating native tools are disabled through schema-derived detection plus a static backstop, and write/unknown MCP tools are hidden and runtime-blocked for that turn.
|
- Plan mode is policy, not prompt advice: mutating native tools are disabled through schema-derived detection plus a static backstop, and write/unknown MCP tools are hidden and runtime-blocked for that turn.
|
||||||
|
|
||||||
@@ -133,73 +134,12 @@ When an email reader is active, browser chat passes active email metadata and th
|
|||||||
|
|
||||||
## Degraded Behavior
|
## Degraded Behavior
|
||||||
|
|
||||||
- ToolIndex can degrade to keyword selection when embeddings, Chroma, index warmup, or vector retrieval timeouts fail.
|
- ToolIndex can degrade to keyword selection when embeddings, Chroma, index
|
||||||
- Agent mode can degrade from native function schemas to prompted fenced-block parsing based on provider/tool-support heuristics. Legacy local Ollama `/v1` and native `/api` endpoints normally use text tools unless explicitly supported. Compact native surfaces force native schemas unless a text-recovery path is selected; configured compact/full modes and the external request contract participate in surface selection, so the legacy default is not universal.
|
warmup, or vector retrieval timeouts fail.
|
||||||
|
- Agent mode can degrade from native function schemas to prompted fenced-block parsing based on provider/tool-support heuristics. Local Ollama `/v1` and native `/api` endpoints default to text tools unless the endpoint explicitly advertises `supports_tools`; `gpt-oss` remains text-tool by default unless the endpoint opts in.
|
||||||
- MCP startup failure is non-critical; route/status surfaces expose per-server errors.
|
- MCP startup failure is non-critical; route/status surfaces expose per-server errors.
|
||||||
- `ODYSSEUS_DISABLE_MCP`, missing `mcp`, uncached browser MCP packages, and per-server disabled tools can remove tools without blocking the app.
|
- `ODYSSEUS_DISABLE_MCP`, missing `mcp`, uncached browser MCP packages, and per-server disabled tools can remove tools without blocking the app.
|
||||||
- Native browser page execution is unavailable at the common boundary; browser registration and schemas do not override that denial.
|
- Global `builtin_browser` disable behavior may not currently match qualified `mcp__builtin_browser__*` tool names.
|
||||||
|
|
||||||
## Request Authority, Resource Identity, And Completion
|
|
||||||
|
|
||||||
`src.turn_contract` owns immutable request-scoped tool selection and required-read operations. Selection and retrieval never grant permission. `src.agent_runtime.authority` owns server admission independently of offered schemas; `execute_tool_block()` requires an explicit security context and a bound `RequestAuthority`, intersects child authority with its parent, normalizes exact operations, and checks policy before resource resolution or dispatch. Delegated credentials have a separate early denial backstop.
|
|
||||||
|
|
||||||
`src.agent_runtime.resources`, `resource_binding`, `owned_resources`, `remote_resources`, and `process_resources` bind operations to server-owned filesystem, owned-record, backend/configuration, process/job, and browser identities. Exact approvals seal these bindings and revalidate them before use; a changed backend, document, path identity, process incarnation, or browser state can refuse replay. Control-plane stores are excluded from native file operations.
|
|
||||||
|
|
||||||
`src.agent_runtime.journal` records proposed, normalized, authorized, dispatched, and settled actions. `effects`, `effect_adapters`, and `effect_log` distinguish intent, executor outcome, observed state, and verified effects; a successful receipt alone does not establish resulting state. `completion.with_completion_gate` decorates `stream_agent_loop`, holds answer deltas until the generator unwinds, and checks completion against the server journal/evidence ledger while tool/progress/interaction events remain live. Cancellation cannot emit successful completion. `src.agent_trace` adapts native SSE, typed traces, and imported trajectories into evidence/completion views.
|
|
||||||
|
|
||||||
`src.agent_runtime.runtime_selection` is the shared compact-preview eligibility rule. `src.clean_agent_preview` runs the opt-in clean v3 loop when its contract has `clean_compact_v3_preview` selection mode; regular/full-schema and compatibility surfaces retain the legacy loop. Preview schema validation, bounded retry/search/tool budgets, and evidence-based finish checks supplement the common executor rather than replacing its authority.
|
|
||||||
|
|
||||||
Current native tools also include `apply_patch`, `get_workspace`, `host_shell`, `private_browser`, `get_weather`, `youtube_tool`, `inspect_media`, `extract_text`, `transcribe_media`, and `search_chats`. Filesystem, subprocess, web, weather, and media handlers own these implementations; `src.media_ingress` owns bounded local attachment ingestion, and OCR/transcription can depend on optional backends.
|
|
||||||
|
|
||||||
## Compact Selection And Evidence Recovery
|
|
||||||
|
|
||||||
`src.turn_contract` shares broad-web briefing classification with preview. Named private stores, active editor work, concrete local media and explicit operations take precedence over incidental public lookup words. Standalone social turns and full-match supplied-text proofreading/translation (including correction-only colon payloads) offer no tools; compound save/search requests remain separate. A subjectless lookup can request clarification only without conversation referents or supplied workspace/media/editor context. These lexical guards neither resolve arbitrary language nor grant authority. A core tool floor applies only without recognized families, exact selected tools or a sealed read, and is still intersected with inventory/policy; browser is not a core tool.
|
|
||||||
|
|
||||||
Explicit discovery-and-page retrieval can select both web tools; workspace artifact requirements can retain bounded file/Python tools alongside web selection. Memory-use prohibitions suppress sealed memory prerequisites. Concrete media routes prefer OCR for exact text, transcription for captions, otherwise inspection; explicit downstream artifacts or personal actions remain distinct. Tool-family selection and multilingual heuristics do not guarantee execution order or semantic understanding.
|
|
||||||
|
|
||||||
Preview rejects blank search queries rather than copying conversational text. It preserves requested temporal/news constraints and adds a PDF constraint only when asked. Discovery is bounded separately from source retrieval: no-evidence/repeated cosmetic searches suppress further search while discovered URLs can remain inspectable. Broad briefing recovery permits one breadth expansion after one successful search and fewer than two attempts, within the remaining round budget; an empty second search cannot restart that expansion. One shallow-answer repair uses an 80-word-or-missing-source-URL heuristic, not substantive grounding proof. Breadth work precedes presentation-only citation/content repair.
|
|
||||||
|
|
||||||
Retrieval qualification uses bounded body/access/missing-page/repetition heuristics, including embedded article text and a 100-word/12-shingle duplicate detector. A successful retrieval prompts assessment of missing, stale or contradictory evidence rather than forcing completion. Browser fallback proposals cannot override the common page-execution denial. Failed transports can lead to alternative offered search/source retrieval, not an authorized automatic browser session. A rejected search may synthesize only from earlier usable retrieval. Terminal budget fallback preserves up to five deduplicated source strings with explicit preliminary/insufficient-evidence wording, or reports no usable evidence. Synthesized citations are not automatically filled from arbitrary discovered URLs; deterministic link-only answers require a positive whole-request grammar and enough extracted links. Unknown official-source identity is not inferred from PDF extensions or product words.
|
|
||||||
|
|
||||||
Native tool choice normalization can make a uniquely offered named search required, narrow schemas before omitting an unsupported provider choice, or use named/auto compatibility for specific DeepSeek/Kimi/Qwen/Ajax families. It is serialization compatibility, not provider acceptance. Wire messages strip server-only provenance and empty assistant placeholders while preserving legitimate tool-call carriers. Sibling tool receipts remain contiguous before bounded untrusted visual blocks; still-image repeat identity includes path, casefolded query and size. Visual defaults retain one current record/three frames with a bounded 1–8 frame control and retire prior tool pixels separately from user uploads.
|
|
||||||
|
|
||||||
Artifact completion is target-specific: off-target/scratch writes and generic successful shell receipts cannot satisfy a required artifact or force early synthesis. Empty runner declarations fall back to prompt-inferred artifacts. Directory requests require meaningful files; source-code writes can unwrap leading/trailing Markdown fences for allowlisted suffixes without certifying syntax. Successful workspace paths can display as `/workspace`, without implying universal path redaction. A reserved final synthesis and tightly bounded malformed-call recovery do not create extra execution authority. `turn_contract` and `agent_step` expose required artifacts/calls-used/pending/write-phase fields; these are progress telemetry, not completion proof. Execution timing metrics record attempted canonical tools even on failure.
|
|
||||||
|
|
||||||
## Admission And Bounded Execution Details
|
|
||||||
|
|
||||||
Chat-scope bearer admission resolves the credential owner and intersects delegated tool grants with server policy; client runtime metadata, retrieval results and prompted tool names cannot widen it. Interactive approval/session options are refused for delegated credentials. Persistent chat grants are server-signed; a normal new turn retires stale pending approvals, while exact first-action replay is one-use. For a non-inherited ungranted launch lacking a backend launch scope, `ToolApprovalStore.create()` may seal exactly one request resource root (or its sealed workspace fallback); inherited children cannot gain this scope. Local reads through delegated credentials, including local `web_fetch`, file-scheme/workspace reads and absolute-path PDF extraction, have an early denial backstop even when the automatic action gate is disabled or bypassed.
|
|
||||||
|
|
||||||
`ODYSSEUS_TOOL_APPROVAL_GATE` defaults on; only normalized `0`, `false`, `no` and `off` disable it. The gate does not replace authority/resource checks. Request authority binds owner, request/thread, workspace and admitted operations; child scopes intersect the parent. Owned record revisions, backend/configuration epochs, filesystem identities and process/job generations are revalidated. Captured server producer observations supply evidence; arbitrary tool output or registry membership is not a trusted producer. Expected admission/resource failures become typed denials; unexpected programming errors are not silently converted into authorization success.
|
|
||||||
|
|
||||||
Native file tools use the canonical agent workspace and explicit allowed roots, deny control-plane paths and all hardlinked regular files, and validate path/resource identities. `ls`, `glob` and `grep` share one control-plane snapshot per scan; root and bound-resource checks still observe fresh state. Grep normalizes relative-or-basename glob filters before bounded ripgrep batches, validates regex even with no targets, closes stdin and retains a bounded Python fallback with identity revalidation. Empty file writes require explicit clear intent for nonempty existing files; missing/null content is refused, already-empty files can be no-ops, and missing files use exclusive/staged creation without overwriting a competing creator. These checks do not make filesystem operations universally atomic or race-free.
|
|
||||||
|
|
||||||
Bounded required-safe reads use an exact sealed operation from the admitted family; a presentation `max_items` limit is not an owner/database filter. Only one eligible active family is warmed and a failed read does not authorize probing alternate families. Bounded model context keeps actual visible records rather than hidden private overflow. Native call identifiers survive only when arguments match exactly; structured turn replacement preserves tool/result pairing. Supplied documents, email, Notes/Todos, skills and workspace instructions remain untrusted reference data.
|
|
||||||
|
|
||||||
Compact preview keeps connections alive without replaying executed tools. It permits at most two qualifying context-size retries per turn with reconstructed paired messages and one pre-response transport retry; yielded stream content is not retried. Pre-execution schema hints are freshly generated from offered schemas/arguments (bounded required/type/enum/additional-property/path guidance). Caught execution exceptions instead use fixed public messages and error categories; raw exception details can remain in server logs. Terminal provider errors use fixed public failure events and do not emit a successful done event.
|
|
||||||
|
|
||||||
Repeated successful exact calls are suppressed while changed arguments or pagination can proceed; repeated failed exact calls and ignored corrective nudges have separate bounded stop rules. Artifact workflows reserve bounded calls and require actual usable artifacts, not merely directory existence or a tool receipt. Compact interactive rounds clamp to 1–200 with an eight-round fallback, and nonpositive interactive tool budgets use a large sentinel. Validated native workspaces instead clamp to 1–64 rounds with an eight-round fallback and a fixed 32-call budget. Legacy adaptive handling and UI limits are separate. Budgets are checked between rounds and do not interrupt an already-running call. Optional verifier retries and teacher escalation are separate recovery controls; teacher work starts after the parent stream contexts close.
|
|
||||||
|
|
||||||
Durable effects distinguish intended mutations, execution outcome and observed state. Claims are persisted before execution; failure to establish a durable claim prevents execution. The newest eligible authorized observation for a resource wins; partial/undecidable evidence cannot borrow an older success, unknown impacts overlap conservatively, and later mutations/new incarnations stale earlier reads. Whole-file postconditions are all-or-none when derivation is unsupported; partial reads are not whole-file digests. Failed reads establish absence only after confined repeated ENOENT checks. Verifier cancellation remains unsettled; changed undeclared targets contradict completion. Only owned running processes survive as running effects; background settlement does not by itself verify output. External execution may finish with disclosed unverified effects, rather than claiming local containment or verified remote state. Scheduled built-ins and arbitrary remote systems are not all covered by this journal.
|
|
||||||
|
|
||||||
## Artifact Recovery And Completion Claims
|
|
||||||
|
|
||||||
Artifact delivery contracts narrow offered writer schemas on copied schema objects and bind declared targets without treating directory descendants or filename patterns as filesystem confinement. Binary suffixes refuse text-writer delivery, including relevant bridge paths, and prefer an offered capable writer. Empty, malformed, off-contract, or non-materialized output uses bounded shared recovery attempts; pending sibling calls and late results do not justify claiming an artifact exists. Python literal-output analysis is a planning aid, not execution evidence.
|
|
||||||
|
|
||||||
Compact tool-call parsing accepts bounded concatenated complete JSON objects with ordered call identities and normal admission checks. Limited trailing tool markup after a complete supported call is inert; arbitrary prose is not an executable call. Artifact research reserves bounded call/time budget for delivery rather than guaranteeing interruption of an already-running tool.
|
|
||||||
|
|
||||||
Completion assessment in `src.agent_runtime` distinguishes a usable artifact, a satisfied obligation, and verified execution. File evidence uses exact normalized identity and bounded regular-file reads, rejecting symlink/nonregular aliases rather than trusting basename matches. Partial reads do not prove whole-file verification, late observations do not retroactively verify earlier claims, and executable verifier results take precedence over optimistic prose.
|
|
||||||
|
|
||||||
Final presentation can remove unsupported execution, test-count, coverage, or all-fixed assertions while preserving quoted/explanatory text. It does not invent new obligations from fabricated assistant claims. Provider errors, approval waits, and budget exhaustion remain distinct terminal states; a polished answer is not an execution receipt. Child journals remain separate, teacher review follows closed child execution, and incomplete children do not become successful distillation evidence. Explicit no-web filtering cannot be undone by an unrelated memory/toolset early return.
|
|
||||||
|
|
||||||
## Parser And Diagnostic Ownership
|
|
||||||
|
|
||||||
`src.text_scanning` supplies forward-only shared scans for the existing staged tool grammars, preserving delimiter priority, mandatory terminal-dot captures and legacy newline behavior. The parser does not become a universal linear-time or arbitrary-language recognizer. Binding catches expected input/resource errors as curated denials; programming defects such as AttributeError are allowed to propagate rather than being relabeled as policy refusal.
|
|
||||||
|
|
||||||
Filesystem postconditions derive exact requested content from the producer transforms, including edit newline behavior and universal-newline patch prestate, with bounded identity-checked UTF-8 reads. Unsupported derivation clears the complete obligation set instead of verifying a convenient subset. Only a process-bound launch or detachment can attest RUNNING; unrelated returned lifecycle keys, arbitrary containment dictionaries and external acknowledgements cannot manufacture native cleanup or job observations.
|
|
||||||
|
|
||||||
## Trusted MCP Attribution
|
|
||||||
|
|
||||||
`src/tool_execution.py` strips caller-provided `_odysseus_*` fields from qualified/legacy MCP JSON and parsed tool arguments. Dispatch then injects trusted owner/session context for email and trusted owner for image MCP tools; missing trusted context does not preserve a forged claim. `_call_mcp_tool()` forwards owner through native/unavailable/disconnected direct fallback paths. Image results from qualified dispatch receive promoted image fields. The image MCP server persists the trimmed owner and registers fresh files; this transport attribution does not replace route authorization.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
@@ -207,7 +147,11 @@ Filesystem postconditions derive exact requested content from the producer trans
|
|||||||
- Agent prompts remain heavy for small local context windows.
|
- Agent prompts remain heavy for small local context windows.
|
||||||
- Some AI-control helpers are still globally wired from app startup rather than a narrower service layer.
|
- Some AI-control helpers are still globally wired from app startup rather than a narrower service layer.
|
||||||
- Tool registry consistency is manual across handler maps, tags, aliases, schemas, retrieval descriptions, execution dispatch, settings/model routes, and frontend toggles.
|
- Tool registry consistency is manual across handler maps, tags, aliases, schemas, retrieval descriptions, execution dispatch, settings/model routes, and frontend toggles.
|
||||||
- Registry and retrieval consistency still requires coordinated updates across native and MCP surfaces.
|
- MCP disabled-tool changes can stale-cache tool retrieval because disabled maps are not always an index generation input.
|
||||||
- External MCP output still needs a single central size cap before model re-entry; untrusted-result metadata and the post-external-context action gate now cover the prompt-injection/authorization boundary.
|
- External MCP output still needs a single central size cap before model re-entry; untrusted-result metadata and the post-external-context action gate now cover the prompt-injection/authorization boundary.
|
||||||
- Auth-disabled/no-login owner propagation is inconsistent between route dependencies and chat/agent execution, so tool-security and native tool storage behavior need dedicated regression coverage.
|
- Auth-disabled/no-login owner propagation is inconsistent between route dependencies and chat/agent execution, so tool-security and native tool storage behavior need dedicated regression coverage.
|
||||||
- Authority, resources, effects, containment, preview, and turn contracts have focused regression suites; live provider and platform behavior still requires separate validation.
|
- Agent tests mostly cover helpers and targeted regressions, including round-cap
|
||||||
|
and disconnect cancellation paths, but not an end-to-end fake-LLM
|
||||||
|
`stream_agent_loop` path with retrieval, native schemas, prompted blocks,
|
||||||
|
disabled/admin hiding, MCP tools, plan/workspace state, user-time context, and
|
||||||
|
tool-result SSE.
|
||||||
|
|||||||
+38
-42
@@ -1,6 +1,6 @@
|
|||||||
# Auth And Security
|
# Auth And Security
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -20,7 +20,8 @@ This spec covers current security and trust-boundary behavior in:
|
|||||||
- `src/prompt_security.py`;
|
- `src/prompt_security.py`;
|
||||||
- `src/url_safety.py` and `src/url_security.py`;
|
- `src/url_safety.py` and `src/url_security.py`;
|
||||||
- `src/host_docker_access.py`;
|
- `src/host_docker_access.py`;
|
||||||
- `src/attachment_refs.py` and upload lifecycle enforcement in `src/upload_handler.py` / `routes/upload_routes.py`;
|
- `src/attachment_refs.py` and upload lifecycle enforcement in
|
||||||
|
`src/upload_handler.py` / `routes/upload_routes.py`;
|
||||||
- `src/secret_storage.py`;
|
- `src/secret_storage.py`;
|
||||||
- `src/api_key_manager.py`;
|
- `src/api_key_manager.py`;
|
||||||
- `src/integrations.py`;
|
- `src/integrations.py`;
|
||||||
@@ -55,7 +56,7 @@ Reserved usernames include request-only sentinels `internal-tool`, `api`, `demo`
|
|||||||
|
|
||||||
Public/auth-exempt surfaces are limited to setup, signup/login/logout/status, feature/settings/integration preset reads, health/version/login, `/static/*`, and task webhook trigger paths. `routes/task/task_routes.py` owns validation of `POST /api/tasks/{task_id}/webhook/{token}` path credentials.
|
Public/auth-exempt surfaces are limited to setup, signup/login/logout/status, feature/settings/integration preset reads, health/version/login, `/static/*`, and task webhook trigger paths. `routes/task/task_routes.py` owns validation of `POST /api/tasks/{task_id}/webhook/{token}` path credentials.
|
||||||
|
|
||||||
Login issues an `HttpOnly`, `SameSite=Lax` cookie with a seven-day max age when "remember" is enabled. `_secure_cookie()` (`routes/auth_routes.py`) decides the `Secure` attribute: an explicit `SECURE_COOKIES` of `true` or `false` is authoritative, and any other value, including unset and the present-but-empty value docker-compose injects, derives it from the request, marking the cookie `Secure` when the connection scheme or the first `X-Forwarded-Proto` hop is https. TOTP is checked before session issuance. Logout, password changes, user deletion, rename flows, expired sessions, and deleted-user sessions must keep revocation/migration behavior intact.
|
Login issues an `HttpOnly`, `SameSite=Lax` cookie with a seven-day max age when "remember" is enabled. `_secure_cookie()` (`routes/auth_routes.py:89`) decides the `Secure` attribute: an explicit `SECURE_COOKIES` of `true` or `false` is authoritative, and any other value, including unset and the present-but-empty value docker-compose injects, derives it from the request, marking the cookie `Secure` when the connection scheme or the first `X-Forwarded-Proto` hop is https. TOTP is checked before session issuance. Logout, password changes, user deletion, rename flows, expired sessions, and deleted-user sessions must keep revocation/migration behavior intact.
|
||||||
|
|
||||||
Deleting a user revokes that user's browser sessions and API-token rows, then the admin delete route invalidates the in-memory bearer-token cache so already-cached tokens stop authenticating.
|
Deleting a user revokes that user's browser sessions and API-token rows, then the admin delete route invalidates the in-memory bearer-token cache so already-cached tokens stop authenticating.
|
||||||
|
|
||||||
@@ -67,26 +68,40 @@ Admin promotion/demotion is a live auth flag change through `AuthManager.set_adm
|
|||||||
|
|
||||||
Cookie requests use the real username. Bearer-token requests are stamped as `request.state.current_user = "api"` plus `api_token_owner`, `api_token_scopes`, and token id. Routes that support API-token access must explicitly use `effective_user()` or route-local scope helpers instead of treating `"api"` as an owner.
|
Cookie requests use the real username. Bearer-token requests are stamped as `request.state.current_user = "api"` plus `api_token_owner`, `api_token_scopes`, and token id. Routes that support API-token access must explicitly use `effective_user()` or route-local scope helpers instead of treating `"api"` as an owner.
|
||||||
|
|
||||||
Internal loopback calls may stamp `current_user = "internal-tool"` or a validated `X-Odysseus-Owner` username. Network/proxy validation for that bypass lives in `app.py`. Native shell/PID and local Cookbook controls add resource-bound admission and reject an internal header or sentinel as human administration. This boundary is surface-specific: common `core.middleware.require_admin()` still admits a matching internal token or sentinel, including MCP configuration routes; producer-side tool policy is a separate check.
|
Internal loopback calls may stamp `current_user = "internal-tool"` or a validated `X-Odysseus-Owner` username. Network/proxy validation for that bypass lives in `app.py`; `require_admin()` trusts the stamped sentinel or raw internal header and should be used behind equivalent middleware control.
|
||||||
|
|
||||||
Missing-owner values remain state-dependent at legacy call sites, but new storage-facing code has one normalization contract:
|
Missing-owner values remain state-dependent at legacy call sites, but new storage-facing code has one normalization contract:
|
||||||
|
|
||||||
- Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies.
|
- Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies.
|
||||||
- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode. Native shell and local Cookbook administration additionally require a direct loopback connection without proxy forwarding, cross-site indicators, or an internal-tool header. Remote/proxied anonymous traffic remains denied at these controls. Auth-enabled administration still requires a human admin. This mode trusts local programs as well as the local operator: a headerless loopback request cannot identify which local program sent it. Agent program launches retain inherited networking; this is not protection against hostile local code. Use authenticated mode when local programs are outside that trust. Local Cookbook tools use a separate one-use capability for an admitted exact request/operation/native backend and resolved launch body; that capability cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes.
|
- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode.
|
||||||
|
Native shell and local Cookbook administration additionally require a direct
|
||||||
|
loopback connection without proxy forwarding, cross-site indicators, or an
|
||||||
|
internal-tool header. Remote/proxied anonymous traffic remains denied at
|
||||||
|
these controls. Auth-enabled administration still requires a human admin.
|
||||||
|
This mode trusts local programs as well as the local operator: a headerless
|
||||||
|
loopback request cannot identify which local program sent it. Agent program
|
||||||
|
launches retain inherited networking; this is not protection against hostile
|
||||||
|
local code. Use authenticated mode when local programs are outside that trust.
|
||||||
|
Local Cookbook tools use a separate one-use capability for an admitted exact
|
||||||
|
request/operation/native backend and resolved launch body; that capability
|
||||||
|
cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes.
|
||||||
- Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state.
|
- Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state.
|
||||||
- SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller.
|
- SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller.
|
||||||
- `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior.
|
- `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior.
|
||||||
- `__odysseus_local__` is a valid storage owner but never a login or request sentinel. Adoption is incremental: callers that do not use the storage-owner helper can still expose older `None`/empty/null compatibility behavior.
|
- `__odysseus_local__` is a valid storage owner but never a login or request sentinel. Adoption is incremental: callers that do not use the storage-owner helper can still expose older `None`/empty/null compatibility behavior.
|
||||||
|
|
||||||
Authenticated `manage_tasks` mutations require an exact stored task-owner match and reject both cross-owner and legacy null-owner rows. The `owner=None` agent path keeps deliberate auth-disabled single-user compatibility, including unscoped list/create/mutation behavior.
|
Authenticated `manage_tasks` mutations require an exact stored task-owner
|
||||||
|
match and reject both cross-owner and legacy null-owner rows. The `owner=None`
|
||||||
|
agent path keeps deliberate auth-disabled single-user compatibility, including
|
||||||
|
unscoped list/create/mutation behavior.
|
||||||
|
|
||||||
Owner-scoped route code should use `require_user()` or equivalent policy before querying per-owner data. Current note CRUD/reorder/reminder routes do this so an auth-enabled request that reaches the route without identity returns `401` instead of falling into single-user/null-owner compatibility behavior.
|
Owner-scoped route code should use `require_user()` or equivalent policy before querying per-owner data. Current note CRUD/reorder/reminder routes do this so an auth-enabled request that reaches the route without identity returns `401` instead of falling into single-user/null-owner compatibility behavior.
|
||||||
|
|
||||||
Scheduled task actions attribute differently again. `_execute_action` (`src/task_scheduler.py`) invokes the action with `owner=task.owner` read from the stored `ScheduledTask` row, so no request and no resolved principal are in flight. These trigger paths converge there: schedule, event bus, manual run (`routes/task/task_routes.py`), the `manage_tasks` agent tool (`src/tools/system.py`), webhook triggers (`routes/task/task_routes.py`), which are unauthenticated by design with the token as the only credential and execute under the stored `task.owner`, and success-chained tasks (`src/task_scheduler.py`), which additionally require the chained target to share `task.owner` and reject cycles. Trigger-side ownership checks use the `if user and task.owner != user` shape, so a falsy caller skips them. Action bodies that reach owner-scoped storage must treat `task.owner` as the authority; route-level `require_user()` never runs on this path.
|
Scheduled task actions attribute differently again. `_execute_action` (`src/task_scheduler.py:1231`) invokes the action with `owner=task.owner` read from the stored `ScheduledTask` row, so no request and no resolved principal are in flight. These trigger paths converge there: schedule, event bus, manual run (`routes/task/task_routes.py:865`), the `manage_tasks` agent tool (`src/tools/system.py:469`), webhook triggers (`routes/task/task_routes.py:1045`), which are unauthenticated by design with the token as the only credential and execute under the stored `task.owner`, and success-chained tasks (`src/task_scheduler.py:1063-1074`), which additionally require the chained target to share `task.owner` and reject cycles. Trigger-side ownership checks use the `if user and task.owner != user` shape, so a falsy caller skips them. Action bodies that reach owner-scoped storage must treat `task.owner` as the authority; route-level `require_user()` never runs on this path.
|
||||||
|
|
||||||
## API Tokens And Scoped Integrations
|
## API Tokens And Scoped Integrations
|
||||||
|
|
||||||
`routes/api_token_routes.py` owns token CRUD and scope normalization. Partial updates preserve existing scopes unless new scopes are supplied, write scopes imply the matching read scopes where applicable, and Cookbook scopes are part of the normalized scope set. `app.py` builds a complete replacement active-prefix map and publishes it by reference, including `app.state`, before verifying bearer tokens with bcrypt. Readers holding an older map are not emptied; this does not guarantee freshest revocation visibility or serialize overlapping refreshes. API-token requests set `request.state.current_user = "api"` plus token owner/scopes.
|
`routes/api_token_routes.py` owns token CRUD and scope normalization. Partial updates preserve existing scopes unless new scopes are supplied, write scopes imply the matching read scopes where applicable, and Cookbook scopes are part of the normalized scope set. `app.py` caches active token prefix rows and verifies bearer tokens with bcrypt. API-token requests set `request.state.current_user = "api"` plus token owner/scopes.
|
||||||
|
|
||||||
Current call sites include Codex/Claude scoped APIs, `/api/v1/chat`, webhooks, selected session routes, companion pairing, and external integrations. `/api/codex/*` and `/api/v1/chat` enforce route-local scopes; companion and selected session routes use owner attribution. `companion/pairing.py` can mint chat-scoped tokens outside normal token CRUD.
|
Current call sites include Codex/Claude scoped APIs, `/api/v1/chat`, webhooks, selected session routes, companion pairing, and external integrations. `/api/codex/*` and `/api/v1/chat` enforce route-local scopes; companion and selected session routes use owner attribution. `companion/pairing.py` can mint chat-scoped tokens outside normal token CRUD.
|
||||||
|
|
||||||
@@ -122,12 +137,21 @@ Current untrusted surfaces include fetched URLs, web results, emails, memories,
|
|||||||
- `src/url_security.py` owns public HTTP(S) validation for integration/API-token supplied URLs. It should fail closed for private IP, loopback, invalid scheme, and unsafe redirect targets.
|
- `src/url_security.py` owns public HTTP(S) validation for integration/API-token supplied URLs. It should fail closed for private IP, loopback, invalid scheme, and unsafe redirect targets.
|
||||||
- `src/url_safety.py` owns local-first outbound URL safety for model endpoints and similar local services. Loopback/LAN can be allowed by default, and private-IP blocking is an explicit caller policy. Strict `block_private=True` also rejects RFC 6598 shared/CGNAT space (`100.64.0.0/10`) explicitly because Python does not classify that range as private.
|
- `src/url_safety.py` owns local-first outbound URL safety for model endpoints and similar local services. Loopback/LAN can be allowed by default, and private-IP blocking is an explicit caller policy. Strict `block_private=True` also rejects RFC 6598 shared/CGNAT space (`100.64.0.0/10`) explicitly because Python does not classify that range as private.
|
||||||
- `core.log_safety.redact_url()` strips URL userinfo, query strings, and fragments before endpoint URLs enter logs. Model, chat/research endpoint, contact/CardDAV, and similar diagnostics should use this helper instead of logging raw admin-configured URLs.
|
- `core.log_safety.redact_url()` strips URL userinfo, query strings, and fragments before endpoint URLs enter logs. Model, chat/research endpoint, contact/CardDAV, and similar diagnostics should use this helper instead of logging raw admin-configured URLs.
|
||||||
- `src.webhook_manager` validates webhook URLs at create and delivery time, rejects private/internal targets, disables redirects, and pins delivery to the public IP set that passed validation immediately before the request.
|
- `src.webhook_manager` validates webhook URLs at create and delivery time,
|
||||||
- `src.integrations` owns admin-configured integration base URLs and secret masking. `api_call` accepts only relative paths, rejects link-local/metadata destinations through `src.url_safety`, can additionally block RFC1918/loopback/private targets with `INTEGRATION_API_BLOCK_PRIVATE_IPS=true`, and pins requests to the IP set that passed SSRF validation while preserving the intended Host/TLS identity.
|
rejects private/internal targets, disables redirects, and pins delivery to
|
||||||
|
the public IP set that passed validation immediately before the request.
|
||||||
|
- `src.integrations` owns admin-configured integration base URLs and secret
|
||||||
|
masking. `api_call` accepts only relative paths, rejects link-local/metadata destinations through `src.url_safety`, can additionally block RFC1918/loopback/private targets with `INTEGRATION_API_BLOCK_PRIVATE_IPS=true`, and pins requests to the IP set that passed SSRF validation while preserving the intended Host/TLS identity.
|
||||||
- `src.outbound_fetch` owns reusable public-URL classification, validates every redirect hop, rejects private/local resolved addresses, and pins the HTTP connection to the validated public IP while preserving original URL/SNI/Host semantics. `services.search.content` adapts that transport for extraction and caching.
|
- `src.outbound_fetch` owns reusable public-URL classification, validates every redirect hop, rejects private/local resolved addresses, and pins the HTTP connection to the validated public IP while preserving original URL/SNI/Host semantics. `services.search.content` adapts that transport for extraction and caching.
|
||||||
- Path-based tools, upload/document/gallery/signature/generated-image routes, embedding cache paths, and research JSON helpers must stay confined to allowed roots and owner-scoped files. Native file/code-navigation tools also apply a case-insensitive sensitive-path denylist so `grep`, `glob`, `ls`, direct reads, and writes cannot reveal `.env`, SSH/GPG material, private-key filenames, or similar secret paths.
|
- Path-based tools, upload/document/gallery/signature/generated-image routes, embedding cache paths, and research JSON helpers must stay confined to allowed roots and owner-scoped files. Native file/code-navigation tools also apply a case-insensitive sensitive-path denylist so `grep`, `glob`, `ls`, direct reads, and writes cannot reveal `.env`, SSH/GPG material, private-key filenames, or similar secret paths.
|
||||||
- Durable upload references are owner-reserved before chat/session, document, note, or calendar writes. Cleanup scans every current durable reference surface and fails closed on incomplete discovery or inconsistent upload-index state rather than deleting a possibly live upload.
|
- Durable upload references are owner-reserved before chat/session, document,
|
||||||
- File-backed SQLite startup restricts `app.db` and existing rollback/WAL/SHM sidecars to `0600` on POSIX after resolving the real path from the parsed engine URL. Windows, in-memory, and non-SQLite databases are excluded, and failed POSIX restriction is logged as a secret-file warning.
|
note, or calendar writes. Cleanup scans every current durable reference
|
||||||
|
surface and fails closed on incomplete discovery or inconsistent upload-index
|
||||||
|
state rather than deleting a possibly live upload.
|
||||||
|
- File-backed SQLite startup restricts `app.db` and existing rollback/WAL/SHM
|
||||||
|
sidecars to `0600` on POSIX after resolving the real path from the parsed
|
||||||
|
engine URL. Windows, in-memory, and non-SQLite databases are excluded, and
|
||||||
|
failed POSIX restriction is logged as a secret-file warning.
|
||||||
- Secret-like DB columns use `EncryptedText` or `src.secret_storage`. Email passwords and Google OAuth mail tokens are encrypted manually in `EmailAccount` string columns; Google OAuth state is HMAC-signed and callback writes are owner-checked before token storage. `src.api_key_manager` keeps provider API keys encrypted in `data/api_keys.json`, writes by loading the raw encrypted dict so saving one provider does not rewrite other providers' keys as plaintext, and restricts local key-file permissions where the platform supports chmod. Vault state in `data/vault.json` is a chmod-restricted JSON secret store, not Fernet-encrypted DB storage. Do not log or return decrypted secrets except for intentional admin vault retrieval flows with audit/reason checks.
|
- Secret-like DB columns use `EncryptedText` or `src.secret_storage`. Email passwords and Google OAuth mail tokens are encrypted manually in `EmailAccount` string columns; Google OAuth state is HMAC-signed and callback writes are owner-checked before token storage. `src.api_key_manager` keeps provider API keys encrypted in `data/api_keys.json`, writes by loading the raw encrypted dict so saving one provider does not rewrite other providers' keys as plaintext, and restricts local key-file permissions where the platform supports chmod. Vault state in `data/vault.json` is a chmod-restricted JSON secret store, not Fernet-encrypted DB storage. Do not log or return decrypted secrets except for intentional admin vault retrieval flows with audit/reason checks.
|
||||||
- `.env` files are secrets-only inputs and should not be read or printed during agent work.
|
- `.env` files are secrets-only inputs and should not be read or printed during agent work.
|
||||||
|
|
||||||
@@ -146,39 +170,11 @@ Host Docker socket access is a high-trust admin/deployment choice, not a normal
|
|||||||
- Legacy migrations claim null-owner SQL/JSON data for the primary admin when possible, and startup repeats a null-owner sweep hourly. Remaining null-owner rows are surface-specific compatibility data that must be deliberately included, no-oped for single-user mode, or rejected for strict ownership gates.
|
- Legacy migrations claim null-owner SQL/JSON data for the primary admin when possible, and startup repeats a null-owner sweep hourly. Remaining null-owner rows are surface-specific compatibility data that must be deliberately included, no-oped for single-user mode, or rejected for strict ownership gates.
|
||||||
- `.env` is loaded with `utf-8-sig`, so Windows BOM auth flags still parse.
|
- `.env` is loaded with `utf-8-sig`, so Windows BOM auth flags still parse.
|
||||||
|
|
||||||
## Server Admission And Model Execution
|
|
||||||
|
|
||||||
`src.agent_runtime.authority.RequestAuthority` is server-owned admission separate from schema availability or intent selection. `execute_tool_block()` requires explicit security context, verifies owner/session/workspace binding, intersects delegated child authority, applies policy denial before approval/bridge dispatch, and resolves typed backend/filesystem/owned/process/browser resource identities. Exact approvals seal and revalidate these bindings; task/chat taint grants cannot manufacture absent backend or resource authority.
|
|
||||||
|
|
||||||
Native filesystem operations use `src.path_confinement` and `src.agent_runtime.resources` to reject sensitive/control-plane paths, including runtime effect stores and aliases. Native model shell/Python paths use enforcing containment with required filesystem, process-tree, and wall-clock dimensions; missing required enforcement fails before spawn. This is distinct from direct operator/admin HTTP shell access and declared external execution bridges. Network and resource-limit dimensions are not automatically promised by the default requirements.
|
|
||||||
|
|
||||||
`core.auth.AuthManager.reset_user_password()` permits an admin to reset a non-admin account and revokes that account’s sessions. Admin targets and non-admin callers are refused.
|
|
||||||
|
|
||||||
## Scoped Admission And Public Error Details
|
|
||||||
|
|
||||||
Browser chat/session/history bearer support requires chat scope and resolves the token owner. Delegated callers cannot approve actions, inherit browser chat grants, supply raw session credentials or bypass route validation. Resolved persistent chat grants are HMAC signed and checked; client metadata removes authority-bearing fields. Parent/child task and teacher authority intersects, and local file/workspace/PDF reads have delegated early denials independent of the optional action gate. The automatic high-impact gate defaults on, disabled only by explicit normalized false values; stale threat-model prose describing unrestricted model filesystem access is not the current native contract.
|
|
||||||
|
|
||||||
Local-first URL checks classify IPv4-mapped addresses by embedded IPv4 and decode exactly `64:ff9b::/96` with strict embedded-target policy, even when the caller allows private services. Other translation prefixes receive no inferred exemption. IPv6 loopback is checked before reserved-address denial and follows the configured private policy. Mail connection helpers validate the resolved address set and pin actual TCP connection; scholarly metadata validation does not inherit that stronger transport claim.
|
|
||||||
|
|
||||||
Host/client run/read/write/patch/poll/cancel bridge clients disable environment proxy trust and carry exact admitted authority, while external execution remains unattested local containment. MCP backend/config and Vault unlock/catalog identities are epoch/revision bound. Native browser page actions and raw global Playwright calls are currently denied unconditionally; metadata-only `session_info` does not restore them. Native filesystem root checks reject NUL/CRLF and all hardlinked regular files, but do not imply every OS path operation is atomic.
|
|
||||||
|
|
||||||
Preview validation may expose bounded schema-derived argument guidance before dispatch; caught execution exceptions use fixed public errors/categories. Raw exception text can remain in server logs. Transport/provider terminal errors are distinct from successful completion, and public assertion filtering is not a general secret scrubber. Upload reference reservations, scoped owners and runtime credential custody continue to apply independently.
|
|
||||||
|
|
||||||
## Configuration And Generated-Media Boundaries
|
|
||||||
|
|
||||||
Auth-route app configuration uses `_require_admin_user()` to honor explicit disabled-auth operator mode for settings/features, document-style extraction and integrations; user-store administration still requires an admin identity. Settings GET exposes full settings to the disabled-auth operator, while auth-enabled anonymous/non-admin reads remain scrubbed. This is a trusted deployment mode, not an anonymous multi-user grant.
|
|
||||||
|
|
||||||
Generated-image serving checks filename syntax before identity, then `effective_user()` (cookie or API-token owner) before disk access. A live process-local registration, gallery owner, linked session owner, or owned-chat metadata reference can authorize access; absence/mismatch returns 404 and database verification errors return 503. Disabled auth skips ownership checks. Responses use private revalidation and nosniff headers. Tool argument parsers strip caller `_odysseus_*` fields before server context injects trusted image/email owner/session attribution; see `gallery-editor-media.md` and `agent-tools.md`.
|
|
||||||
|
|
||||||
## Upload Route Authorization
|
|
||||||
|
|
||||||
Upload routes distinguish disabled-auth operation from unavailable/unconfigured auth infrastructure: explicit no-login bypasses owner gates; auth-enabled missing manager is 503, unconfigured manager 401 and missing/sentinel identity 403, without loopback fallback. Interactive admin override remains available for non-owned/legacy ownerless files. Delegated API credentials require a real token owner, matching non-null file ownership and an appropriate scope; even an admin-owned token has no cross-owner or ownerless override. Read scopes are any of `chat`, `documents:read`, `documents:write`; upload creation/OCR mutation requires `chat` or `documents:write`. See `documents-rag-uploads.md` for transition behavior.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Direct operator/admin HTTP shell execution remains powerful; model-code containment and typed admission must not be generalized into a sandbox claim for every administrative surface.
|
- There is no shell/filesystem sandbox for admin tools.
|
||||||
- Token scopes remain coarse for some surfaces.
|
- Token scopes remain coarse for some surfaces.
|
||||||
- Auth policy, scoped tokens, and request authority have focused regression tests; full middleware composition and live proxy deployments still need separate validation.
|
- `app.py` AuthMiddleware lacks direct regression coverage for bearer-token state/cache behavior, trusted-loopback proxy-header rejection, and internal-tool owner stamping.
|
||||||
- Codex/Claude scoped route enforcement still needs stronger regression coverage.
|
- Codex/Claude scoped route enforcement still needs stronger regression coverage.
|
||||||
- `THREAT_MODEL.md` still has stale token-scope and `/api/v1/chat` SSRF gap text that should be reconciled with current route validation.
|
- `THREAT_MODEL.md` still has stale token-scope and `/api/v1/chat` SSRF gap text that should be reconciled with current route validation.
|
||||||
- The Default/Local owner contract is canonical but only incrementally adopted; route helper `""`, chat/agent `None`, SQL/JSON null-owner compatibility, and calendar fallback owner behavior still need domain-by-domain migration decisions.
|
- The Default/Local owner contract is canonical but only incrementally adopted; route helper `""`, chat/agent `None`, SQL/JSON null-owner compatibility, and calendar fallback owner behavior still need domain-by-domain migration decisions.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Calendar, Tasks, And Notes
|
# Calendar, Tasks, And Notes
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ This spec covers calendar, reminders, tasks, assistant runs, and notes in:
|
|||||||
- scoped Codex wrappers in `routes/codex_routes.py`;
|
- scoped Codex wrappers in `routes/codex_routes.py`;
|
||||||
- database models `CalendarCal`, `CalendarEvent`, `ScheduledTask`, `TaskRun`, `Note`, and `CrewMember`;
|
- database models `CalendarCal`, `CalendarEvent`, `ScheduledTask`, `TaskRun`, `Note`, and `CrewMember`;
|
||||||
- direct DB CLIs `scripts/odysseus-calendar`, `scripts/odysseus-notes`, and `scripts/odysseus-tasks`;
|
- direct DB CLIs `scripts/odysseus-calendar`, `scripts/odysseus-notes`, and `scripts/odysseus-tasks`;
|
||||||
- frontend modules `static/js/calendar.js`, `static/js/calendar/*`, `static/js/tasks.js`, the `static/js/notes.js` compatibility facade and maintained `frontend/src/features/notes/` graph deployed under `static/js/features/notes/`, and `static/js/assistant.js`;
|
- frontend modules `static/js/calendar.js`, `static/js/calendar/*`, `static/js/tasks.js`, `static/js/notes.js`, and `static/js/assistant.js`;
|
||||||
- tests covering calendar routes/utilities, CalDAV, recurrence, timezone handling, scheduler behavior, task webhooks, notes CLI/tool behavior, and task CLI behavior.
|
- tests covering calendar routes/utilities, CalDAV, recurrence, timezone handling, scheduler behavior, task webhooks, notes CLI/tool behavior, and task CLI behavior.
|
||||||
|
|
||||||
## Calendar
|
## Calendar
|
||||||
@@ -53,8 +53,6 @@ Cookbook serve scheduling crosses this domain. The Cookbook UI creates `cookbook
|
|||||||
|
|
||||||
`routes.task.task_routes` owns task CRUD, status, manual run/stop/cancel, pause/resume, owner-scoped run/activity history, metadata, onboarding defaults, cache clearing, parse endpoints, and webhook-token regeneration. `app.py` imports the canonical package path; `routes/task_routes.py` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Chained-task `then_task_id` values are validated as same-owner relationships on create/update, and scheduler execution also rejects cross-owner or cyclic chains.
|
`routes.task.task_routes` owns task CRUD, status, manual run/stop/cancel, pause/resume, owner-scoped run/activity history, metadata, onboarding defaults, cache clearing, parse endpoints, and webhook-token regeneration. `app.py` imports the canonical package path; `routes/task_routes.py` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Chained-task `then_task_id` values are validated as same-owner relationships on create/update, and scheduler execution also rejects cross-owner or cyclic chains.
|
||||||
|
|
||||||
`src.tools.system.do_manage_tasks()` translates structured schedules: `day_of_month` is an integer 1–31 for monthly tasks, while recurring `weekdays` names plus `scheduled_time` in UTC `HH:MM` become a cron expression. Conflicting day/cron inputs are rejected. One-off `scheduled_date` values are parsed to naive UTC and must be future dates; recurring schedules reject supplied one-off dates, and invalid cron expressions are refused. A time-only edit of an existing five-field cron rewrites its minute/hour fields while preserving calendar fields and recomputes `next_run`; it does not merely update the legacy display clock.
|
|
||||||
|
|
||||||
Task webhook paths are auth-exempt at the app middleware layer only for `/api/tasks/{task_id}/webhook/{token}`. The route still validates active task state plus task-specific webhook token before dispatch.
|
Task webhook paths are auth-exempt at the app middleware layer only for `/api/tasks/{task_id}/webhook/{token}`. The route still validates active task state plus task-specific webhook token before dispatch.
|
||||||
|
|
||||||
Task runtime behavior:
|
Task runtime behavior:
|
||||||
@@ -67,16 +65,25 @@ Task runtime behavior:
|
|||||||
- event-bus triggers persist counters and `next_run` before scheduler handoff;
|
- event-bus triggers persist counters and `next_run` before scheduler handoff;
|
||||||
- the in-process scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`, and multiple enabled app processes can double-run work.
|
- the in-process scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`, and multiple enabled app processes can double-run work.
|
||||||
- action tasks with `run_local`, `run_script`, `ssh_command`, or
|
- action tasks with `run_local`, `run_script`, `ssh_command`, or
|
||||||
`cookbook_serve` are admin-only. `routes.task_routes` enforces this on create/update/manual run and hides those actions from `/meta/actions` for non-admin owners; webhook and scheduler execution pause the task and clear `next_run` if an admin-only action belongs to a non-admin owner.
|
`cookbook_serve` are admin-only. `routes.task_routes` enforces this on
|
||||||
|
create/update/manual run and hides those actions from `/meta/actions` for
|
||||||
|
non-admin owners; webhook and scheduler execution pause the task and clear
|
||||||
|
`next_run` if an admin-only action belongs to a non-admin owner.
|
||||||
- background LLM task execution uses the background workload path, and the
|
- background LLM task execution uses the background workload path, and the
|
||||||
scheduler can abort/cancel active in-process task runs when foreground browser activity appears.
|
scheduler can abort/cancel active in-process task runs when foreground browser
|
||||||
|
activity appears.
|
||||||
- `tidy_research` scans all persisted research files because broken JSON has no trustworthy owner stamp, so it runs only for admins or the explicit auth-disabled single-user operator and refuses regular/pre-setup callers before enumeration.
|
- `tidy_research` scans all persisted research files because broken JSON has no trustworthy owner stamp, so it runs only for admins or the explicit auth-disabled single-user operator and refuses regular/pre-setup callers before enumeration.
|
||||||
|
|
||||||
`routes.assistant_routes.py` owns crew/assistant settings and run-status surfaces that use the scheduler. `TaskScheduler.ensure_assistant_defaults()` currently seeds the personal assistant crew member and pinned assistant session, but no longer auto-creates Morning/Midday/Evening check-in tasks. Existing crew-linked check-in tasks are still rendered and managed when present.
|
`routes.assistant_routes.py` owns crew/assistant settings and run-status surfaces that use the scheduler. `TaskScheduler.ensure_assistant_defaults()` currently seeds the personal assistant crew member and pinned assistant session, but no longer auto-creates Morning/Midday/Evening check-in tasks. Existing crew-linked check-in tasks are still rendered and managed when present.
|
||||||
|
|
||||||
## Notes And Reminders
|
## Notes And Reminders
|
||||||
|
|
||||||
`routes.note.note_routes` owns notes/todos/reminders, and `app.py` imports that canonical path. `routes.note_routes` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Notes are SQLAlchemy `Note` rows and can include due dates, ordering, images, repeat state, AI classification, source/session provenance, and agent session linkage.
|
`routes.note.note_routes` owns notes/todos/reminders, and `app.py` imports that
|
||||||
|
canonical path. `routes.note_routes` replaces its module entry with the
|
||||||
|
canonical module for legacy import and monkeypatch compatibility. Notes are
|
||||||
|
SQLAlchemy `Note` rows and can include due dates, ordering, images, repeat
|
||||||
|
state, AI classification, source/session provenance, and agent session
|
||||||
|
linkage.
|
||||||
|
|
||||||
Notes CRUD/reorder/reminder routes resolve the acting owner through `require_user()`: auth-enabled anonymous requests fail closed before hitting owner-scoped queries, while documented no-login/single-user modes still resolve to the compatibility owner path.
|
Notes CRUD/reorder/reminder routes resolve the acting owner through `require_user()`: auth-enabled anonymous requests fail closed before hitting owner-scoped queries, while documented no-login/single-user modes still resolve to the compatibility owner path.
|
||||||
|
|
||||||
@@ -98,15 +105,17 @@ Reminder dispatch is Note-owned:
|
|||||||
|
|
||||||
Email/ntfy failures degrade into channel result fields rather than blocking every reminder path. ntfy and generic webhook reminder URLs run through outbound URL safety checks, with `REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS` controlling whether private/LAN targets are allowed. ntfy notification titles are converted to ASCII with replacement and capped at 200 characters before entering HTTP headers. Reminder dedupe uses owner-scoped cache files under `data/`.
|
Email/ntfy failures degrade into channel result fields rather than blocking every reminder path. ntfy and generic webhook reminder URLs run through outbound URL safety checks, with `REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS` controlling whether private/LAN targets are allowed. ntfy notification titles are converted to ASCII with replacement and capped at 200 characters before entering HTTP headers. Reminder dedupe uses owner-scoped cache files under `data/`.
|
||||||
|
|
||||||
`src.builtin_actions.action_ping_notes()` counts delivery only when `dispatch_reminder()` reports success for the configured browser/email/ntfy/webhook channel. Typed receipts in `data/note_pings_<owner-slug>.json` record time and channel. A browser fallback or legacy timestamp does not prove external delivery: the next eligible scan retries the configured external channel without queueing the recent browser fallback again. The scanner reloads dispatcher-owned receipts before pruning so it cannot replace a successful channel receipt with a timestamp; total delivery failure is not checkpointed. Retries occur only within 90 seconds of the due time, while recent matching-channel receipts suppress delivery for 25 minutes. Existing retry tests cover webhook/ntfy failures, legacy receipts, browser dedupe, total failure, and receipt-preserving pruning; live channel delivery remains separate validation.
|
|
||||||
|
|
||||||
## Agent, Codex, And CLI Surfaces
|
## Agent, Codex, And CLI Surfaces
|
||||||
|
|
||||||
`do_manage_tasks`, `do_manage_notes`, and `do_manage_calendar` own agent-side writes. `do_manage_calendar` supports batch event creation plus list range aliases (`start`, `start_time`, `start_date`, `range_start`, `from`, `dtstart`, `since`, and matching end aliases), calendar name/short-id lookup, importance/tag aliases, and reminder offsets expressed as numbers, minute/hour words, or common abbreviations such as `min`/`mins`/`hr`/`hrs`. If a model supplies a loose `query`, `date_range`, or `range` without explicit start/end datetimes, `list_events` returns an error asking the caller to resolve the range and call again instead of guessing. Event classification reads `Memory.text` for personal context before LLM classification. `src.tool_index` encodes the reminder policy that notes/todos own reminders while calendar events own time blocks.
|
`do_manage_tasks`, `do_manage_notes`, and `do_manage_calendar` own agent-side writes. `do_manage_calendar` supports batch event creation plus list range aliases (`start`, `start_time`, `start_date`, `range_start`, `from`, `dtstart`, `since`, and matching end aliases), calendar name/short-id lookup, importance/tag aliases, and reminder offsets expressed as numbers, minute/hour words, or common abbreviations such as `min`/`mins`/`hr`/`hrs`. If a model supplies a loose `query`, `date_range`, or `range` without explicit start/end datetimes, `list_events` returns an error asking the caller to resolve the range and call again instead of guessing. Event classification reads `Memory.text` for personal context before LLM classification. `src.tool_index` encodes the reminder policy that notes/todos own reminders while calendar events own time blocks.
|
||||||
|
|
||||||
Agent native tool owner handling is not uniform today. `do_manage_tasks()` filters lists only when `owner` is truthy and creates tasks with the passed owner, so `owner=None` can create legacy/null-owner tasks. For authenticated/non-empty owners, edit/delete/pause/resume/run require an exact stored owner match and reject both cross-owner and null-owner rows; `owner=None` retains single-user compatibility. `do_manage_notes()` list/query behavior distinguishes `None` from `""`, with `None` acting as broader single-user compatibility while `""` filters to empty-owner rows in some paths. `do_manage_calendar()` query helpers filter only when owner is not `None`, while calendar creation routes through the calendar fallback owner for default calendars. These are compatibility behaviors, not a cross-user sharing model.
|
Agent native tool owner handling is not uniform today. `do_manage_tasks()` filters lists only when `owner` is truthy and creates tasks with the passed owner, so `owner=None` can create legacy/null-owner tasks. For authenticated/non-empty owners, edit/delete/pause/resume/run require an exact stored owner match and reject both cross-owner and null-owner rows; `owner=None` retains single-user compatibility. `do_manage_notes()` list/query behavior distinguishes `None` from `""`, with `None` acting as broader single-user compatibility while `""` filters to empty-owner rows in some paths. `do_manage_calendar()` query helpers filter only when owner is not `None`, while calendar creation routes through the calendar fallback owner for default calendars. These are compatibility behaviors, not a cross-user sharing model.
|
||||||
|
|
||||||
Note and calendar route/tool writers owner-reserve any canonical internal upload references in content, checklist/color/image fields, descriptions, and locations before their database writes. Missing or wrong-owner uploads fail the write instead of creating a dangling durable reference; reservations serialize with upload cleanup.
|
Note and calendar route/tool writers owner-reserve any canonical internal upload
|
||||||
|
references in content, checklist/color/image fields, descriptions, and
|
||||||
|
locations before their database writes. Missing or wrong-owner uploads fail the
|
||||||
|
write instead of creating a dangling durable reference; reservations serialize
|
||||||
|
with upload cleanup.
|
||||||
|
|
||||||
Chat forwards browser timezone offset and IANA timezone name so natural-language note/calendar tools can anchor dates to the user clock. A valid IANA zone wins over the fixed offset for current-time/DST reasoning; invalid or absent names fall back to the offset and then server-local/UTC compatibility behavior. Chat can auto-promote note/calendar/reminder intents to agent mode.
|
Chat forwards browser timezone offset and IANA timezone name so natural-language note/calendar tools can anchor dates to the user clock. A valid IANA zone wins over the fixed offset for current-time/DST reasoning; invalid or absent names fall back to the offset and then server-local/UTC compatibility behavior. Chat can auto-promote note/calendar/reminder intents to agent mode.
|
||||||
|
|
||||||
@@ -162,32 +171,9 @@ Existing coverage is strongest around CalDAV URL hardening/writeback, client cle
|
|||||||
|
|
||||||
Route-level coverage is thinner for full calendar route behavior, task CRUD/security/run controls, live webhook token dispatch, notes owner CRUD/reminder delivery, assistant defaults/run status, event-bus triggers, Codex todo/calendar scopes, and frontend panel wiring.
|
Route-level coverage is thinner for full calendar route behavior, task CRUD/security/run controls, live webhook token dispatch, notes owner CRUD/reminder delivery, assistant defaults/run status, event-bus triggers, Codex todo/calendar scopes, and frontend panel wiring.
|
||||||
|
|
||||||
## Calendar Reminder And Invitation State
|
|
||||||
|
|
||||||
Calendar event responses now include reminder note id, due time, minutes-before, and source-email provenance. Create/update accept `reminder_minutes`; an explicitly supplied null clears the reminder, while omission preserves it. Reminders are owner-scoped `Note` todos with calendar label/source. Passed events skip creation, and a reminder whose intended time passed is clamped to now. Legacy matching uses generated title plus due time rather than an event UID; deleting reminders uses the title/source/owner match, so similarly named events remain a compatibility-sensitive boundary. `GET /api/calendar/events/{uid}` resolves recurring occurrence IDs back to the owner-visible base event.
|
|
||||||
|
|
||||||
`src/email_calendar_import.py` owns invitation revision state through `EmailCalendarInvitation`: identity hashes owner, normalized sender, ICS UID, and optional recurrence id instead of letting untrusted ICS UIDs address arbitrary local events. Sequence/DTSTAMP ordering suppresses stale replays, cancellation wins equal-version ties and leaves tombstones, and detached occurrences exclude their original series instance. Fixed striped file locks serialize imports across processes. Unsupported reply methods are ignored, and `THISANDFUTURE` revisions require a replacement series.
|
|
||||||
|
|
||||||
Task create/update persist sealed `request_authority_json`; execution binds that authority to LLM/research work rather than deriving permission from task output. Task endpoint resolution uses owner-visible registered endpoint bases or their exact derived chat URL before runtime credentials are attached; scheme, host, effective port and full trimmed path must match, with no userinfo, parameters, query or fragment. Legacy shared endpoint visibility is retained. The scheduler helper catches credential resolution exceptions and can continue with empty headers, so this is not a universal no-network-on-resolution-failure guarantee. Authority/effect contracts are owned by `src/agent_runtime`, not the scheduling layer.
|
|
||||||
|
|
||||||
## Scheduler Cache And Native Task Details
|
|
||||||
|
|
||||||
Scheduler `_cached` requests share one pending future per key with a TTL. Waiters shield the shared work from their own cancellation; completion/failure removes only the matching pending future, and failures can retry rather than being cached as success. Endpoint discovery separately caches successful empty Tailscale results, while discovery failures do not become successful empty cache entries.
|
|
||||||
|
|
||||||
Task/assistant endpoint updates accept registered owner-visible routes, canonicalize empty values to no override and reject invalid routes. Local shell/script/Cookbook scheduled actions require their sealed native backend capability; missing capabilities fail closed and raw external/SSH execution cannot borrow it. Headless task/background continuations close their streams in the originating task, preserve authority, honor replacement final responses and distinguish provider failure from successful completion.
|
|
||||||
|
|
||||||
`NotificationLog` GET is owner-scoped and non-consuming, defaults to 200 rows and clamps to 1000. Notification insert title/body limits are 200/2000 characters; notification text does not grant task authority. Calendar reminder matching is legacy title/source/owner-based rather than UID-linked: nearest eligible due time within zero to seven days of the base event start can match, while deletion removes all same-title matches rather than discriminating by exact alarm time. Negative offsets clamp to zero, passed events do not create reminders, passed alarm time clamps to now, explicit null clears and omission preserves.
|
|
||||||
|
|
||||||
## Notes Frontend State And Lifetimes
|
|
||||||
|
|
||||||
The canonical Notes composition entry owns one `createNotesState()` instance, injected HTTP/storage/notification ports, application workflows and UI factories. Domain modules own note rules and local-time recurrence. Application modules own query/order, optimistic submission/card/checklist/deletion, selection, drafts, agent actions and reminders; UI modules delegate mutations and transport through those operations. The legacy facade preserves default/named open/close/toggle/new/openNote/badge methods and aliases, including named-only reminder-dot dismissal, without exposing mutable state.
|
|
||||||
|
|
||||||
Background startup is idempotent: after three seconds it loads active notes, then starts a 30-second reminder interval and immediate check. Close/minimize/force-close dispose panel resources but leave that page-owned interval running. Scans use the current shared note collection, skip archived/date-only/fired notes, fire within the previous minute, and advance or mark older reminders; archived-only/error-cleared lists can still defer active reminders. Server synthesis has a 1.5-second local-notification fallback, with toast/highlight persistence. This browser path remains separate from backend delivery receipts.
|
|
||||||
|
|
||||||
Drafts persist synchronously on input/change and serialize existing-note network autosaves after 500 ms; successful saves clear only the matching draft, while failure/new/drawing drafts remain recoverable. Canvas bitmaps are not draft JSON. Explicit drawing save awaits PNG upload before optimistic submission. Selection dispatches `odysseus:notes-context-selected` synchronously and exits only when the consumer sets `accepted`; chat retains context/session authority. Closing releases owned portals/listeners/gestures/observers, and late fetch/masonry work checks panel connectivity/open state. It does not add universal async generation guards: fetch remains last-completion-wins, optimistic creation replaces the first remaining temporary note, and bulk actions can swallow individual failures before refetch/reporting requested counts.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
|
- CardDAV still needs URL hardening parity with CalDAV; CalDAV now resolves hostnames during validation and revalidates writeback URLs.
|
||||||
- `do_manage_notes()` should match HTTP note-route owner behavior for legacy null-owner notes.
|
- `do_manage_notes()` should match HTTP note-route owner behavior for legacy null-owner notes.
|
||||||
- Auth-disabled agent tools can produce or read broader owner scopes than route handlers because they receive `owner=None`; tasks, notes, and calendar need aligned policy/tests.
|
- Auth-disabled agent tools can produce or read broader owner scopes than route handlers because they receive `owner=None`; tasks, notes, and calendar need aligned policy/tests.
|
||||||
- Task webhook tests should keep exercising live route token behavior and
|
- Task webhook tests should keep exercising live route token behavior and
|
||||||
|
|||||||
+41
-38
@@ -1,19 +1,20 @@
|
|||||||
# Chat
|
# Chat
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This spec covers current chat behavior in:
|
This spec covers current chat behavior in:
|
||||||
|
|
||||||
- `routes/chat_routes.py` and `routes/chat_helpers.py`;
|
- `routes/chat_routes.py` and `routes/chat_helpers.py`;
|
||||||
- `routes/session_routes.py` and canonical `routes/history/history_routes.py`, with `routes/history_routes.py` as a compatibility shim;
|
- `routes/session_routes.py` and canonical `routes/history/history_routes.py`,
|
||||||
|
with `routes/history_routes.py` as a compatibility shim;
|
||||||
- `src/chat_helpers.py`;
|
- `src/chat_helpers.py`;
|
||||||
- `src/agent_runs.py` and `src/background_tool_jobs.py`;
|
- `src/agent_runs.py`;
|
||||||
- `src/turn_contract.py`, `src/request_models.py`, and `src/agent_runtime/` request authority/runtime selection/context resolution;
|
|
||||||
- `src/chat_handler.py` and `src/chat_processor.py`;
|
- `src/chat_handler.py` and `src/chat_processor.py`;
|
||||||
- `core/session_manager.py` and `core/models.py`;
|
- `core/session_manager.py` and `core/models.py`;
|
||||||
- `src/attachment_refs.py` and `src/upload_handler.py` for durable attachment references and write reservations;
|
- `src/attachment_refs.py` and `src/upload_handler.py` for durable attachment
|
||||||
|
references and write reservations;
|
||||||
- `src/context_budget.py`, `src/context_compactor.py`, and `src/topic_analyzer.py`;
|
- `src/context_budget.py`, `src/context_compactor.py`, and `src/topic_analyzer.py`;
|
||||||
- `src/foreground_model_routing.py`, `src/tool_approval_scopes.py`, `src/tool_approvals.py`, and `src/tool_capabilities.py`;
|
- `src/foreground_model_routing.py`, `src/tool_approval_scopes.py`, `src/tool_approvals.py`, and `src/tool_capabilities.py`;
|
||||||
- `routes/workspace_routes.py` for workspace selection support;
|
- `routes/workspace_routes.py` for workspace selection support;
|
||||||
@@ -40,10 +41,16 @@ Runtime behavior:
|
|||||||
- browser chat sends `X-Tz-Offset` and an IANA timezone name; request-local helpers prefer a valid IANA zone for DST-aware current-time reasoning, then fall back to the fixed offset;
|
- browser chat sends `X-Tz-Offset` and an IANA timezone name; request-local helpers prefer a valid IANA zone for DST-aware current-time reasoning, then fall back to the fixed offset;
|
||||||
- browser chat can send a selected workspace path; route code only resolves it for admin/single-user flows, validates it as an existing directory, and forwards it so agent file/shell tools are confined by `src.tool_execution`;
|
- browser chat can send a selected workspace path; route code only resolves it for admin/single-user flows, validates it as an existing directory, and forwards it so agent file/shell tools are confined by `src.tool_execution`;
|
||||||
- stream callbacks can outlive a deleted session, so persistence must fail closed instead of recreating orphan messages;
|
- stream callbacks can outlive a deleted session, so persistence must fail closed instead of recreating orphan messages;
|
||||||
- message metadata carries timestamps, metrics, tool events, sources, hidden thinking/reasoning text when providers expose it separately, context-trim metrics, structured attachment references, and related UI state;
|
- message metadata carries timestamps, metrics, tool events, sources, hidden
|
||||||
|
thinking/reasoning text when providers expose it separately, context-trim
|
||||||
|
metrics, structured attachment references, and related UI state;
|
||||||
- metadata preserves requested and actual reply models and endpoints, per-round route transitions, and answering-route cost attribution; stable session ids remain available so prompt/sequence-memory and KV-cache paths can address the same conversation consistently;
|
- metadata preserves requested and actual reply models and endpoints, per-round route transitions, and answering-route cost attribution; stable session ids remain available so prompt/sequence-memory and KV-cache paths can address the same conversation consistently;
|
||||||
- multimodal content can be a list of content blocks for the live provider call, while persistence collapses raw media into readable text and stable attachment-reference lines;
|
- multimodal content can be a list of content blocks for the live provider call,
|
||||||
- agent streams forward explicit round-cap, tool-budget, repeated-tool-loop, and intent-without-action guard events so the frontend can distinguish a controlled stop from a stalled response.
|
while persistence collapses raw media into readable text and stable
|
||||||
|
attachment-reference lines;
|
||||||
|
- agent streams forward explicit round-cap, tool-budget, repeated-tool-loop,
|
||||||
|
and intent-without-action guard events so the frontend can distinguish a
|
||||||
|
controlled stop from a stalled response.
|
||||||
|
|
||||||
`src.agent_runs` owns detached in-memory stream runs, replay buffers, replacement cancellation, resume subscribers, explicit stop, and terminal-buffer eviction. Closing the SSE connection does not necessarily stop generation. `static/js/chat.js` can live-resume a still-running detached stream through `/api/chat/resume/{session_id}`; rich responses reload from DB for canonical rendering. Detached runs are process-local and do not survive server restart.
|
`src.agent_runs` owns detached in-memory stream runs, replay buffers, replacement cancellation, resume subscribers, explicit stop, and terminal-buffer eviction. Closing the SSE connection does not necessarily stop generation. `static/js/chat.js` can live-resume a still-running detached stream through `/api/chat/resume/{session_id}`; rich responses reload from DB for canonical rendering. Detached runs are process-local and do not survive server restart.
|
||||||
|
|
||||||
@@ -74,15 +81,30 @@ Current call sites include:
|
|||||||
- deep research orchestration in `src/research_handler.py`;
|
- deep research orchestration in `src/research_handler.py`;
|
||||||
- compare entry points in canonical `routes/compare/compare_routes.py` and frontend compare modules.
|
- compare entry points in canonical `routes/compare/compare_routes.py` and frontend compare modules.
|
||||||
|
|
||||||
Agent-mode tool access is gated in layers. Chat route toggles and privileges build a disabled-tool set; incognito and compare mode remove persistence-heavy or UI-breaking tools; `src.action_intents.message_needs_tools()` provides conservative regex auto-escalation hints; `src.agent_loop`, `src.tool_security`, `src.tool_execution`, and internal loopback validation remain server-side enforcement owners.
|
Agent-mode tool access is gated in layers. Chat route toggles and privileges
|
||||||
|
build a disabled-tool set; incognito and compare mode remove persistence-heavy
|
||||||
|
or UI-breaking tools; `src.action_intents.message_needs_tools()` provides
|
||||||
|
conservative regex auto-escalation hints; `src.agent_loop`,
|
||||||
|
`src.tool_security`, `src.tool_execution`, and internal loopback validation
|
||||||
|
remain server-side enforcement owners.
|
||||||
|
|
||||||
`allow_bash` and `allow_web_search` can be read from the JSON request body for browser chat posts that do not submit traditional form fields.
|
`allow_bash` and `allow_web_search` can be read from the JSON request body for browser chat posts that do not submit traditional form fields.
|
||||||
|
|
||||||
Web search tools are per-turn explicit opt-in. Either `allow_web_search=true` or `use_web=true` can enable `web_search`/`web_fetch`, but an explicit `allow_web_search=false` wins over `use_web=true` and keeps those tools disabled. Explicit latest-turn web-search intent can still auto-escalate into agent mode and narrows the available tool set toward `web_search`/`web_fetch`, but it no longer re-enables web tools after an explicit denial or global disable.
|
Web search tools are per-turn explicit opt-in. Either `allow_web_search=true`
|
||||||
|
or `use_web=true` can enable `web_search`/`web_fetch`, but an explicit
|
||||||
|
`allow_web_search=false` wins over `use_web=true` and keeps those tools
|
||||||
|
disabled. Explicit latest-turn web-search intent can still auto-escalate into
|
||||||
|
agent mode and narrows the available tool set toward `web_search`/`web_fetch`,
|
||||||
|
but it no longer re-enables web tools after an explicit denial or global
|
||||||
|
disable.
|
||||||
|
|
||||||
Guide-only/no-tools requests build an effective tool policy before preprocessing and agent dispatch. That policy suppresses tool-backed preprocessing/background extraction/research, disables schemas and MCP for the turn, and is still enforced by `src.tool_execution` if a model emits a tool call anyway.
|
Guide-only/no-tools requests build an effective tool policy before preprocessing and agent dispatch. That policy suppresses tool-backed preprocessing/background extraction/research, disables schemas and MCP for the turn, and is still enforced by `src.tool_execution` if a model emits a tool call anyway.
|
||||||
|
|
||||||
When route context is trimmed without full compaction, chat emits a `context_trimmed` SSE event and carries before/after message/token counts into metrics. Provider reasoning/thinking deltas are streamed for live UI handling but kept out of the visible saved assistant content and stored in metadata when available.
|
When route context is trimmed without full compaction, chat emits a
|
||||||
|
`context_trimmed` SSE event and carries before/after message/token counts into
|
||||||
|
metrics. Provider reasoning/thinking deltas are streamed for live UI handling
|
||||||
|
but kept out of the visible saved assistant content and stored in metadata when
|
||||||
|
available.
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
@@ -90,11 +112,12 @@ When route context is trimmed without full compaction, chat emits a `context_tri
|
|||||||
|
|
||||||
Attachment-only sends are valid. Missing or unauthorized ids are skipped during preprocessing, while a missing/wrong-owner durable reference aborts a message/history replacement before existing transcript rows are removed. Upload failures keep pending files for retry, unsupported media can degrade to text markers, optional Office/PDF/VL dependencies can emit extraction banners, Office attachments can create markdown documents when extracted server-side, and fillable-PDF auto-document failures fall back to normal PDF extraction. `chat_messages.content` and FTS do not retain provider data URLs; structured references stay in metadata for reloads. Chat does not own upload bytes or durable document storage; it requests document/upload behavior from those subsystems.
|
Attachment-only sends are valid. Missing or unauthorized ids are skipped during preprocessing, while a missing/wrong-owner durable reference aborts a message/history replacement before existing transcript rows are removed. Upload failures keep pending files for retry, unsupported media can degrade to text markers, optional Office/PDF/VL dependencies can emit extraction banners, Office attachments can create markdown documents when extracted server-side, and fillable-PDF auto-document failures fall back to normal PDF extraction. `chat_messages.content` and FTS do not retain provider data URLs; structured references stay in metadata for reloads. Chat does not own upload bytes or durable document storage; it requests document/upload behavior from those subsystems.
|
||||||
|
|
||||||
Frontend resend normally replaces from the selected user turn: it truncates server history before removing that turn and later DOM/thread state, resolves the persisted message id with index fallback, and aborts if truncation fails. Explicit append-only resend appends a fresh copy; attachments are retained through the respective path. AI-message delete prompts before removing the AI response plus preceding user turn. Desktop Enter submits; mobile Enter inserts a newline unless another platform-specific send control is used.
|
Frontend chat distinguishes normal resend from regenerate-from-here: normal resend appends a fresh user copy and carries upload IDs where available, while regeneration truncates from the selected point. AI-message delete prompts before removing the AI response plus preceding user turn. Desktop Enter submits; mobile Enter inserts a newline unless another platform-specific send control is used.
|
||||||
|
|
||||||
The Notes panel can attach selected Note/Todo IDs as reference context for a chat send. `static/js/chat.js` keeps selection in memory per chat session, transfers a pending selection when a new session is materialized, restores the indicator on session switches, and lets the user clear it. A send captures the selected IDs for its current session and includes `selected_note_ids` in the streaming form. This is separate from uploaded attachments and is not durable session storage. The server reloads exact-owner notes and wraps their text as untrusted context; validation and formatting are owned by [context-building.md](context-building.md).
|
Native document tool outputs can open or refresh the document editor from
|
||||||
|
tool-result metadata, so the UI can recover if a later `doc_update` stream event
|
||||||
Native document tool outputs can open or refresh the document editor from tool-result metadata, so the UI can recover if a later `doc_update` stream event is missed. The chat renderer also hides raw/incomplete leaked tool JSON and document fences from normal transcript text.
|
is missed. The chat renderer also hides raw/incomplete leaked tool JSON and
|
||||||
|
document fences from normal transcript text.
|
||||||
|
|
||||||
When untrusted external/workspace content has entered the agent context, high-impact tool calls pause as exact approval cards instead of executing. The browser can allow the rest of the interrupted task, allow this chat session, or deny; it submits only the opaque id/decision with an empty control-plane message and does not mutate the composer. The server restores the sealed first action plus private selected tools/query, revalidates policy and document freshness, consumes the first action, and resumes without persisting a synthetic user message. Task scope ends with that resumed run. Chat scope persists the resolved card and marks later context only for that exact session; forks do not inherit it. A normal message retires an unresolved card while preserving taint.
|
When untrusted external/workspace content has entered the agent context, high-impact tool calls pause as exact approval cards instead of executing. The browser can allow the rest of the interrupted task, allow this chat session, or deny; it submits only the opaque id/decision with an empty control-plane message and does not mutate the composer. The server restores the sealed first action plus private selected tools/query, revalidates policy and document freshness, consumes the first action, and resumes without persisting a synthetic user message. Task scope ends with that resumed run. Chat scope persists the resolved card and marks later context only for that exact session; forks do not inherit it. A normal message retires an unresolved card while preserving taint.
|
||||||
|
|
||||||
@@ -104,7 +127,7 @@ When untrusted external/workspace content has entered the agent context, high-im
|
|||||||
|
|
||||||
The scoped API-token chat surface is `/api/v1/chat`. Browser chat routes can receive bearer-auth state from middleware, but route code must not assume `"api"` is a durable owner; API-token support requires explicit scope checks and token-owner attribution.
|
The scoped API-token chat surface is `/api/v1/chat`. Browser chat routes can receive bearer-auth state from middleware, but route code must not assume `"api"` is a durable owner; API-token support requires explicit scope checks and token-owner attribution.
|
||||||
|
|
||||||
Incognito skips durable user and assistant message writes, supplies the current user turn ephemerally without duplicate insertion, avoids loading saved transcript history, and disables memory/skill/history context paths. This is a message-history contract, not a guarantee that every route, session header or auxiliary operation performs no write.
|
Incognito disables memory, skill, and chat-history tools and skips assistant DB persistence, but current user-message persistence and later cleanup are not a strict no-write guarantee. Treat incognito changes as security-sensitive until that contract is clarified.
|
||||||
|
|
||||||
## Search Boundary
|
## Search Boundary
|
||||||
|
|
||||||
@@ -121,31 +144,11 @@ Incognito skips durable user and assistant message writes, supplies the current
|
|||||||
- Docker/native endpoint differences are owned by runtime/model setup, but chat sessions depend on the saved endpoint URLs and headers.
|
- Docker/native endpoint differences are owned by runtime/model setup, but chat sessions depend on the saved endpoint URLs and headers.
|
||||||
- Copying a response from the UI copies the displayed answer text and omits hidden reasoning/thinking segments.
|
- Copying a response from the UI copies the displayed answer text and omits hidden reasoning/thinking segments.
|
||||||
|
|
||||||
## Turn Preparation And Durable Background Results
|
|
||||||
|
|
||||||
Chat preparation derives request authority separately from tool inventory. `src.turn_contract` selects capability families and required reads for compact/native routes; exact approval continuations, the TUI surface, and full-schema routes opt out of that selection path. Endpoint/model tool modes and the clean-route aliases (`cleanv3`, `preheret`) influence routing; the shared runtime-selection helper determines whether both context preparation and execution use compact v3. Full-schema models retain the complete enabled inventory subject to server permission gates.
|
|
||||||
|
|
||||||
Sessions persist independent memory-extraction, memory-injection, and skill-injection toggles, thinking mode, temperature/max-token overrides, workspace `cwd`, endpoint id, accumulated token totals, and cost. Session hydration restores these values. Generated images are preserved when a session is deleted unless image deletion is explicitly requested.
|
|
||||||
|
|
||||||
`src.background_tool_jobs.BackgroundToolJobs` stores tool-work origin and once-only chat delivery in `BackgroundToolJob` SQL rows. This is separate from process-local detached SSE buffers and disk-backed shell jobs. Result context is reconstructed from background metadata; a durable completion must remain attributed to its originating owner/session.
|
|
||||||
|
|
||||||
## Session Preparation Details
|
|
||||||
|
|
||||||
Credential recovery selects the session's bound enabled URL-matching endpoint before resolving its provider-auth id; a deleted/disabled bound endpoint does not trigger selection of another account. Legacy unbound recovery chooses the oldest enabled owner-visible URL-matching `ModelEndpoint` by creation time/id and persists `endpoint_id` before resolving runtime headers. Client/TUI context is bounded untrusted metadata, not admission, workspace grants or permission to execute supplied verifier commands. Context-info exposes bounded tool/skill/AGENTS path inventory rather than instruction bodies; host existence details are admin-only.
|
|
||||||
|
|
||||||
Header persistence retries selected SQLite locked failures with bounded backoff and can continue in memory after exhaustion; it does not guarantee durability. Session listing uses owner-filtered unarchived database rows, hides synthetic trace sessions and treats empty sessions according to persisted messages/documents/images or live state. Synthetic `sft_` trace capture is enabled by default unless opted out; prefix selection and trash/tombstones are not data erasure guarantees. Auto-title uses a bounded utility call and a short word-based fallback.
|
|
||||||
|
|
||||||
Active document state is owner-checked and chat-bound. An explicitly empty active-document state disables fallback, minimized editors do not resurrect context, and session switches preserve the exact active document rather than selecting the first cached document. A stream run id identifies streamed UI replacement and differs from nested execution-journal identity. Structured turn replacement and terminal error events preserve partial content without claiming a successful completion. Background tool delivery attribution is separate from provider success and disk-backed shell result delivery.
|
|
||||||
|
|
||||||
## History Truncation And Cleanup
|
|
||||||
|
|
||||||
`routes/history/history_routes.py` verifies session ownership before parsing truncation requests. The body must be a JSON object with an explicit `keep_count` or nonblank string `before_msg_id` (`message_id` alias); null/empty primary IDs fall through to the alias, while other nonstrings fail. Counts accept nonnegative integers or integer strings, rejecting booleans, floats, null and negative values. A missing message boundary returns 404 before history mutation; explicit zero remains a supported clear operation. Invalid JSON/targets return 400 without deleting saved messages or SFT trace pairs. Cleanup uses durable message rows and rechecks the count at deletion; see `persistence.md`.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Chat, agent, research, and compare orchestration still meet in a large route file.
|
- Chat, agent, research, and compare orchestration still meet in a large route file.
|
||||||
- Context preface behavior is spread across `routes/chat_helpers.py`, `src/chat_processor.py`, route injections, and agent/tool paths.
|
- Context preface behavior is spread across `routes/chat_helpers.py`, `src/chat_processor.py`, route injections, and agent/tool paths.
|
||||||
- Detached stream lifecycle spans `routes/chat_routes.py`, `src/agent_runs.py`, `static/js/chat.js`, `static/js/sessions.js`, and non-chat callers.
|
- Detached stream lifecycle spans `routes/chat_routes.py`, `src/agent_runs.py`, `static/js/chat.js`, `static/js/sessions.js`, and non-chat callers.
|
||||||
- Some frontend stream state is still global/module-level in `static/js/chat.js` and needs careful session isolation when adding background or resumable flows.
|
- Some frontend stream state is still global/module-level in `static/js/chat.js` and needs careful session isolation when adding background or resumable flows.
|
||||||
- Route scope, stream errors, tool-policy preprocessing, request contracts, and runtime preparation have focused tests; full browser/provider/resume behavior still spans several modules.
|
- Chat lacks route-level SSE regression tests for `/api/chat_stream`, live resume/stop/status, mode handoff, persistence metadata, partial-save behavior, attachment/doc-update events, browser timezone offset/workspace handling, and literal URL context intent.
|
||||||
- Scoped bearer admission and incognito message-history handling have focused regressions; they do not establish a universal no-write or live provider guarantee.
|
- Bearer-token behavior on browser chat routes and incognito persistence need explicit contract decisions and regression coverage.
|
||||||
|
|||||||
+5
-11
@@ -1,6 +1,6 @@
|
|||||||
# Compare
|
# Compare
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -40,9 +40,11 @@ Frontend compare behavior is split by responsibility:
|
|||||||
- `panes.js` and `stream.js` own paired response rendering;
|
- `panes.js` and `stream.js` own paired response rendering;
|
||||||
- `vote.js` and `scoreboard.js` own voting and history display.
|
- `vote.js` and `scoreboard.js` own voting and history display.
|
||||||
|
|
||||||
Compare panes can receive `ask_user` or tool-approval controls from the shared chat stream. `static/js/compare/stream.js` renders shared choice controls inside the originating pane history, pauses pane completion/autograding while a choice is pending, and resumes only while the pane session still matches. Continuation waits for the originating controller to release the pane, restores the card after a ten-second timeout, and refuses to send a stale choice into a replacement controller; compare orchestration keeps its busy state until those continuations settle.
|
Compare panes can receive `ask_user` or tool-approval controls from the shared chat stream. `static/js/compare/stream.js` routes those controls into the main chat renderer/control plane, pauses pane completion/autograding while a choice is pending, and can resume the pane after the user decision; compare orchestration keeps its busy state until those continuations settle.
|
||||||
|
|
||||||
Mobile compare uses selectable pane tabs and one visible active pane; desktop retains the selected multi-column grid. Pane add/remove/shuffle paths keep active-tab and session/settings indices aligned.
|
Mobile compare layout collapses multi-pane grids to a single column so panes
|
||||||
|
remain readable on narrow screens while the desktop grid still uses the
|
||||||
|
selected column count.
|
||||||
|
|
||||||
## Ownership Boundaries
|
## Ownership Boundaries
|
||||||
|
|
||||||
@@ -67,14 +69,6 @@ Compare frontend code is part of the app DOM security surface. Current stream/se
|
|||||||
- Shared legacy `ModelEndpoint.owner == NULL` rows remain visible through owner filters. Legacy `Comparison.owner == NULL` rows are not treated as shared for authenticated vote/delete/history flows.
|
- Shared legacy `ModelEndpoint.owner == NULL` rows remain visible through owner filters. Legacy `Comparison.owner == NULL` rows are not treated as shared for authenticated vote/delete/history flows.
|
||||||
- `/api/compare/start` and `/{comp_id}/vote` remain implemented but are not the active frontend path.
|
- `/api/compare/start` and `/{comp_id}/vote` remain implemented but are not the active frontend path.
|
||||||
|
|
||||||
## Per-Pane Generation Controls
|
|
||||||
|
|
||||||
`static/js/compare/panes.js` owns per-pane inference controls and saves `thinking_mode`, `temperature_override`, and `max_tokens_override` to `/api/session/{id}/generation-settings`. The backend session/provider path owns the actual generation semantics. Pane creation initializes overrides, removal and shuffle preserve index alignment, and mandatory-thinking model controls are disabled where identified. `stream.js` renders response statistics including token/latency/context/cost metrics and the thinking-off indicator; compare header statistics open a compact details popup.
|
|
||||||
|
|
||||||
## Shared Celebration Renderer
|
|
||||||
|
|
||||||
`static/js/compare/vote.js` imports and reexports `spawnConfetti` from `static/js/shared/ui/confetti.js`, published from `frontend/src/shared/ui/confetti.js`. Notes imports the shared renderer directly; voting no longer owns its implementation. Vote behavior and callers retain the compatibility export.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Blind mode is not a confidentiality boundary; client/local state can still expose model identity before vote.
|
- Blind mode is not a confidentiality boundary; client/local state can still expose model identity before vote.
|
||||||
|
|||||||
+10
-25
@@ -1,6 +1,6 @@
|
|||||||
# Context Building
|
# Context Building
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -9,10 +9,10 @@ This spec covers model-context construction in:
|
|||||||
- `src/chat_processor.py`;
|
- `src/chat_processor.py`;
|
||||||
- `src/chat_handler.py` and `src/youtube_handler.py`;
|
- `src/chat_handler.py` and `src/youtube_handler.py`;
|
||||||
- `routes/chat_helpers.py` and context injection in `routes/chat_routes.py`;
|
- `routes/chat_helpers.py` and context injection in `routes/chat_routes.py`;
|
||||||
- `src/agent_loop.py`, `src/clean_agent_preview.py`, `src/turn_contract.py`, and `src/agent_runtime/context_resolution.py`;
|
- `src/agent_loop.py`;
|
||||||
- `src/media_ingress.py`, `src/agent_tools/media_tools.py`, and `src/email_attachment_text.py`;
|
|
||||||
- `src/tool_execution.py`;
|
- `src/tool_execution.py`;
|
||||||
- `src/attachment_refs.py` and uploaded-file manifest construction in `routes/chat_helpers.py`;
|
- `src/attachment_refs.py` and uploaded-file manifest construction in
|
||||||
|
`routes/chat_helpers.py`;
|
||||||
- `src/tool_policy.py`;
|
- `src/tool_policy.py`;
|
||||||
- `src/prompt_security.py`;
|
- `src/prompt_security.py`;
|
||||||
- `src/tool_capabilities.py`, `src/tool_approval_scopes.py`, and `src/tool_approvals.py`;
|
- `src/tool_capabilities.py`, `src/tool_approval_scopes.py`, and `src/tool_approvals.py`;
|
||||||
@@ -53,9 +53,12 @@ Current untrusted context sources include:
|
|||||||
- emails and attachments;
|
- emails and attachments;
|
||||||
- tool output from external/user-controlled data.
|
- tool output from external/user-controlled data.
|
||||||
|
|
||||||
Live multimodal provider blocks can contain data URLs, but persisted and tool-facing context uses stable attachment references. Tool manifests carry an `odysseus://attachment/<id>` URI and owner-checked read policy; local paths are compatibility data added only after owner and root-confinement checks. Persisted chat context keeps readable text/reference lines rather than reinserting raw media bytes into later turns or search state.
|
Live multimodal provider blocks can contain data URLs, but persisted and
|
||||||
|
tool-facing context uses stable attachment references. Tool manifests carry an
|
||||||
`/api/chat_stream` accepts explicitly selected Notes/Todos through the JSON form field `selected_note_ids`. `routes.chat_routes._parse_selected_note_ids()` limits the encoded field to 8192 characters and the array to 50 nonempty string IDs of at most 128 characters, deduplicating valid IDs and returning `400` for malformed input. The route queries only rows whose `Note.owner` exactly matches `effective_user(request)`; missing or other-owner IDs contribute no context. Titles, content, note type, and checklist item state are formatted by `_format_selected_notes_context()` and passed to `routes.chat_helpers.build_chat_context()` as an untrusted "Selected notes and todos" preface. Selection supplies reference data, not tool authority; the preface discourages redundant retrieval or permission requests just to read those same supplied notes.
|
`odysseus://attachment/<id>` URI and owner-checked read policy; local paths are
|
||||||
|
compatibility data added only after owner and root-confinement checks. Persisted
|
||||||
|
chat context keeps readable text/reference lines rather than reinserting raw
|
||||||
|
media bytes into later turns or search state.
|
||||||
|
|
||||||
## URL, Search, And Tool-Derived Context
|
## URL, Search, And Tool-Derived Context
|
||||||
|
|
||||||
@@ -101,24 +104,6 @@ Guide-only/no-tools policy can suppress context acquisition before the model cal
|
|||||||
- `src.tool_execution` for `web_search`, `web_fetch`, file, shell, MCP, and other tool outputs;
|
- `src.tool_execution` for `web_search`, `web_fetch`, file, shell, MCP, and other tool outputs;
|
||||||
- `src.deep_research` and research handlers for search/fetch/extract flows used by research jobs, with fetched webpage text wrapped before extraction and analyzed URLs tracked separately from source snippets.
|
- `src.deep_research` and research handlers for search/fetch/extract flows used by research jobs, with fetched webpage text wrapped before extraction and analyzed URLs tracked separately from source snippets.
|
||||||
|
|
||||||
## Effective Context And Media Evidence
|
|
||||||
|
|
||||||
Compact preview preparation resolves the effective context window once through `src.agent_runtime.context_resolution`; terminal metrics use that stored resolution and do not trigger new discovery. Runtime-confirmed, provider-advertised, operator-declared, static-table, and unknown evidence remain distinct, with conflicts recorded. Operator declarations can cap stronger evidence; unknown is represented as zero rather than an invented known limit. Metadata probing has a three-second preparation deadline, caches remote observations, and re-probes local serving processes. Legacy chat/agent budgeting remains a separate compatibility path.
|
|
||||||
|
|
||||||
`src.context_compactor` trims context and prunes multimodal images. Session memory-injection and skill-injection toggles independently control preface construction; memory extraction is a separate session setting. `src.skill_index` semantically ranks native skills using an already-ready ToolIndex embedding lane, with lexical fallback, while `search_chats` retrieves owner-scoped historical messages instead of making all history ambient context.
|
|
||||||
|
|
||||||
`src.media_ingress` limits file counts, source/encoded bytes, image dimensions/pixels, document characters, video frames, and probe/extraction time. Native media tools return observed media/text evidence through the same untrusted-result path; filenames and model guesses do not establish inspected image/video content. `src.email_attachment_text` supplies shared attachment text extraction for mail context.
|
|
||||||
|
|
||||||
## Resolution And Embedding Limits
|
|
||||||
|
|
||||||
`src.agent_runtime.context_resolution.ContextResolution` binds effective context evidence to the resolved endpoint/model before compact preparation shapes history. The stored resolution feeds terminal metrics without another discovery call. A legacy shaping default does not turn unknown evidence into a known limit; a bare integer cannot supply typed provenance. Metadata requests use the turn's provider headers only at configured or server-resolved provider origins, without credential-following redirects, and endpoint URLs stay out of serialized resolution metrics. Remote observations cache for 600 seconds, failures for 60 seconds, while local servers are re-probed.
|
|
||||||
|
|
||||||
`src.embeddings` accepts an explicit `FASTEMBED_THREADS` integer from 1 through 256 and raises for malformed or out-of-range values rather than silently changing the requested execution setting. This controls embedding setup, not proof of a ready vector service.
|
|
||||||
|
|
||||||
## Compact Source Projection
|
|
||||||
|
|
||||||
Compact editor/email preparation can use current memory and bounded prior-source references without importing arbitrary hidden document bodies. The email classifier supplies dependency evidence for the selected path, not a general model-routing authority. Document edits are bounded batches with per-edit outcomes: a saved `doc_update` is distinct from an unapplied suggestion, exact unique-text matching can reject an ambiguous suggestion, and partial success does not mean every proposed edit was saved.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- URL/search context result shape is not unified across chat prefetch, agent tools, and research.
|
- URL/search context result shape is not unified across chat prefetch, agent tools, and research.
|
||||||
|
|||||||
+3
-15
@@ -1,6 +1,6 @@
|
|||||||
# Cookbook And Hardware Fit
|
# Cookbook And Hardware Fit
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Access policy is split by surface:
|
|||||||
|
|
||||||
- download/setup/SSH key/cache scan/serve/GPU/kill/state/task-status are admin/internal-tool surfaces;
|
- download/setup/SSH key/cache scan/serve/GPU/kill/state/task-status are admin/internal-tool surfaces;
|
||||||
- `/api/cookbook/hf-latest` is authenticated-user gated;
|
- `/api/cookbook/hf-latest` is authenticated-user gated;
|
||||||
- HW Fit routes carry a router-wide admin dependency because host/model-path probes can use server SSH/filesystem authority;
|
- HW Fit routes are authenticated read/probe routes through normal middleware, not admin-only operations;
|
||||||
- bearer API tokens do not satisfy Cookbook admin gates.
|
- bearer API tokens do not satisfy Cookbook admin gates.
|
||||||
|
|
||||||
Runtime behavior:
|
Runtime behavior:
|
||||||
@@ -130,7 +130,7 @@ Runtime behavior:
|
|||||||
|
|
||||||
- Linux, Windows/PowerShell, macOS, Docker, NVIDIA, AMD, Apple Silicon, and CPU-only systems have different command paths.
|
- Linux, Windows/PowerShell, macOS, Docker, NVIDIA, AMD, Apple Silicon, and CPU-only systems have different command paths.
|
||||||
- Remote hosts are accessed through SSH helpers; Cookbook host/port/path inputs must be validated before command construction.
|
- Remote hosts are accessed through SSH helpers; Cookbook host/port/path inputs must be validated before command construction.
|
||||||
- HW Fit detection targets use shared host/port validation before remote probing; the router-wide admin gate still owns admission.
|
- HW Fit remote host/port query values currently do not share all Cookbook route-level validation before SSH probing.
|
||||||
- Missing local tools or failed installs should surface command/output/error detail where possible.
|
- Missing local tools or failed installs should surface command/output/error detail where possible.
|
||||||
- GPU overlays remain optional and do not break CPU-only deployments.
|
- GPU overlays remain optional and do not break CPU-only deployments.
|
||||||
- Docker GPU overlays pass host devices/env; they do not install CUDA/ROCm engines by themselves.
|
- Docker GPU overlays pass host devices/env; they do not install CUDA/ROCm engines by themselves.
|
||||||
@@ -188,18 +188,6 @@ Existing coverage is strongest for helper validation/quoting, SSH host validatio
|
|||||||
|
|
||||||
Route-level auth/security and degraded-return coverage is thinner for Cookbook admin routes, shell dependency routes, `/api/cookbook/hf-latest`, state/status edge cases, HW Fit routes, frontend JS behavior, and helper scripts such as `hf_download.py`, `add_hwfit_models.py`, and `diffusion_server.py`.
|
Route-level auth/security and degraded-return coverage is thinner for Cookbook admin routes, shell dependency routes, `/api/cookbook/hf-latest`, state/status edge cases, HW Fit routes, frontend JS behavior, and helper scripts such as `hf_download.py`, `add_hwfit_models.py`, and `diffusion_server.py`.
|
||||||
|
|
||||||
## Native Control And Adapter Contracts
|
|
||||||
|
|
||||||
`routes/cookbook_routes.py` gates native download/serve producers even when scoped wrappers call them directly. Local tools use a one-use admitted operation/backend/body capability from `src.agent_runtime.local_model_control`; fallback administration uses the shell admin gate. Kill-PID, SSH-key and state mutation do not inherit that model capability. Remote operations retain separate validation and admin requirements.
|
|
||||||
|
|
||||||
Cached-model scans pass request-owned script bytes over subprocess stdin instead of sharing `scan_cache.py`; cancellation/timeout terminates that request's scanner. Image serving accepts a shell-safe explicit `runtime_adapter`, passes it as `ODYSSEUS_MLX_IMAGE_ADAPTER`, and retains `auto` detection with dependency preflights. HW Fit ranking carries image dependency-package metadata; native AWQ rows no longer require multiple GPUs on otherwise supported accelerator platforms.
|
|
||||||
|
|
||||||
## Native Process And Catalog Limits
|
|
||||||
|
|
||||||
Tool-reachable local Cookbook stop refuses legacy unadmitted tmux/process targets with resource-identity denial before scanning or killing; native model capabilities admit only the exact owner/request/backend/body and consume once. Scheduled native serving requires the sealed capability, while remote/operator HTTP flows retain their distinct validation. Procfs discovery returns empty when procfs is unavailable and cannot prove absence or authorize survivor reclamation. Shared process cleanup freezes known descendants, checks start tokens and observes death; uncertain identity is retained, with unavoidable signal/observation races rather than guaranteed portable teardown.
|
|
||||||
|
|
||||||
Image collection refresh has a short in-memory and longer disk cache; network failure can preserve stale model data rather than force an empty catalog. Latest lookup and organization prefilters use external metadata and heuristic first-party/format/parameter sizing; fit estimates (including a 90-percent VRAM budget) are recommendations, not measured serving acceptance. Bundled HF/MLX inventories remain empty in final source, and generated inventory inspection is separate from consumer/schema review.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Cookbook-created model endpoint ownership/shared/null-owner policy needs a deliberate decision.
|
- Cookbook-created model endpoint ownership/shared/null-owner policy needs a deliberate decision.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Documents, RAG, And Uploads
|
# Documents, RAG, And Uploads
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ This spec covers file/document context, document storage, and vector retrieval i
|
|||||||
- `routes/upload_routes.py`, `routes/personal_routes.py`, `routes/embedding_routes.py`, canonical `routes/document/document_routes.py` and `routes/document/document_helpers.py`, plus their top-level compatibility shims;
|
- `routes/upload_routes.py`, `routes/personal_routes.py`, `routes/embedding_routes.py`, canonical `routes/document/document_routes.py` and `routes/document/document_helpers.py`, plus their top-level compatibility shims;
|
||||||
- chat attachment paths in `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, and `src/chat_processor.py`;
|
- chat attachment paths in `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, and `src/chat_processor.py`;
|
||||||
- `core/session_manager.py`, `src/attachment_refs.py`, `src/upload_handler.py`,
|
- `core/session_manager.py`, `src/attachment_refs.py`, `src/upload_handler.py`,
|
||||||
`src/upload_limits.py`, and the public reference contract in `website/attachments.md`;
|
`src/upload_limits.py`, and the public reference contract in
|
||||||
|
`docs/attachments.md`;
|
||||||
- `src/document_processor.py`, `src/document_actions.py`, `src/personal_docs.py`, and `src/markitdown_runtime.py`;
|
- `src/document_processor.py`, `src/document_actions.py`, `src/personal_docs.py`, and `src/markitdown_runtime.py`;
|
||||||
- `src/rag_singleton.py`, `src/rag_vector.py`, `src/rag_manager.py`, `src/chroma_client.py`, `src/embeddings.py`, and `src/embedding_lanes.py`;
|
- `src/rag_singleton.py`, `src/rag_vector.py`, `src/rag_manager.py`, `src/chroma_client.py`, `src/embeddings.py`, and `src/embedding_lanes.py`;
|
||||||
- PDF/form helpers in `src/pdf_runtime.py`, `src/pdf_forms.py`, and `src/pdf_form_doc.py`;
|
- PDF/form helpers in `src/pdf_runtime.py`, `src/pdf_forms.py`, and `src/pdf_form_doc.py`;
|
||||||
@@ -53,17 +54,45 @@ Chat does not own attachment extraction. Runtime flow:
|
|||||||
- `src.document_processor.build_user_content()` produces model-ready text, PDF text, Office/EPUB text when MarkItDown or the DOCX fallback is available, image/multimodal blocks, truncation, and PDF/Office auto-document updates;
|
- `src.document_processor.build_user_content()` produces model-ready text, PDF text, Office/EPUB text when MarkItDown or the DOCX fallback is available, image/multimodal blocks, truncation, and PDF/Office auto-document updates;
|
||||||
- chat streams attachment, PDF-created `doc_update`, and `rag_sources` events where applicable.
|
- chat streams attachment, PDF-created `doc_update`, and `rag_sources` events where applicable.
|
||||||
|
|
||||||
Extensionless image and audio attachments derive their data-URI subtype from the detected MIME type, so `image/png` and `audio/mpeg` uploads do not become invalid `data:image/;base64` or `data:audio/;base64` blocks when the filename has no extension.
|
Extensionless image and audio attachments derive their data-URI subtype from
|
||||||
|
the detected MIME type, so `image/png` and `audio/mpeg` uploads do not become
|
||||||
|
invalid `data:image/;base64` or `data:audio/;base64` blocks when the filename
|
||||||
|
has no extension.
|
||||||
|
|
||||||
## Durable References And Cleanup
|
## Durable References And Cleanup
|
||||||
|
|
||||||
`src.attachment_refs` owns the stable `attachment_ref` shape used outside raw upload storage: attachment id, name, MIME type, size, and optional checksum, creation time, dimensions, vision text/model, and gallery id. Live provider calls may still receive multimodal data URLs for the current turn, but durable chat content is normalized to readable text plus compact reference lines. Structured references remain in message attachment metadata, and chat FTS triggers omit inline media while startup migration scrubs legacy indexed data URLs.
|
`src.attachment_refs` owns the stable `attachment_ref` shape used outside raw
|
||||||
|
upload storage: attachment id, name, MIME type, size, and optional checksum,
|
||||||
|
creation time, dimensions, vision text/model, and gallery id. Live provider
|
||||||
|
calls may still receive multimodal data URLs for the current turn, but durable
|
||||||
|
chat content is normalized to readable text plus compact reference lines.
|
||||||
|
Structured references remain in message attachment metadata, and chat FTS
|
||||||
|
triggers omit inline media while startup migration scrubs legacy indexed data
|
||||||
|
URLs.
|
||||||
|
|
||||||
Agent/tool manifests expose `odysseus://attachment/<id>` with `read_policy: "owner_checked_upload"`. A compatibility filesystem path is included only after owner-aware upload resolution, upload-root confinement, and tool-readable-root checks; the stable contract for external tools is the URI and attachment id, not host layout.
|
Agent/tool manifests expose `odysseus://attachment/<id>` with
|
||||||
|
`read_policy: "owner_checked_upload"`. A compatibility filesystem path is
|
||||||
|
included only after owner-aware upload resolution, upload-root confinement, and
|
||||||
|
tool-readable-root checks; the stable contract for external tools is the URI
|
||||||
|
and attachment id, not host layout.
|
||||||
|
|
||||||
Writers reserve referenced uploads before committing durable state. This includes session message append/replace and history rewrites, document create/update and native document edits, note route/tool create/update, calendar/event route/tool create/update, and attachment-bearing session updates. A missing or wrong-owner reference aborts before destructive replacement and surfaces a route conflict or tool error. Reservations serialize with cleanup through the upload-index lock and refresh access time.
|
Writers reserve referenced uploads before committing durable state. This
|
||||||
|
includes session message append/replace and history rewrites, document
|
||||||
|
create/update and native document edits, note route/tool create/update,
|
||||||
|
calendar/event route/tool create/update, and attachment-bearing session
|
||||||
|
updates. A missing or wrong-owner reference aborts before destructive
|
||||||
|
replacement and surfaces a route conflict or tool error. Reservations serialize
|
||||||
|
with cleanup through the upload-index lock and refresh access time.
|
||||||
|
|
||||||
Admin cleanup first scans chat content and attachment metadata, current and versioned documents including PDF markers, gallery filenames/hashes, note image/color/content/checklist fields, and calendar color/description/location fields. Reference discovery or index-integrity failure aborts cleanup; the lower-level API removes nothing without both completed id and hash snapshots. Only expired, unreferenced files with coherent id/path/owner/checksum/timestamp metadata are candidates. Matching index rows are persisted away before byte deletion and restored if deletion fails. This lock is process-local, so the documented race protection assumes the current single-worker deployment.
|
Admin cleanup first scans chat content and attachment metadata, current and
|
||||||
|
versioned documents including PDF markers, gallery filenames/hashes, note
|
||||||
|
image/color/content/checklist fields, and calendar color/description/location
|
||||||
|
fields. Reference discovery or index-integrity failure aborts cleanup; the
|
||||||
|
lower-level API removes nothing without both completed id and hash snapshots.
|
||||||
|
Only expired, unreferenced files with coherent id/path/owner/checksum/timestamp
|
||||||
|
metadata are candidates. Matching index rows are persisted away before byte
|
||||||
|
deletion and restored if deletion fails. This lock is process-local, so the
|
||||||
|
documented race protection assumes the current single-worker deployment.
|
||||||
|
|
||||||
## Living Documents And PDF
|
## Living Documents And PDF
|
||||||
|
|
||||||
@@ -71,13 +100,19 @@ Admin cleanup first scans chat content and attachment metadata, current and vers
|
|||||||
|
|
||||||
`static/js/documentLibrary.js` owns local library state after archive/delete actions, including total counts and language chips. Server route truth still owns durable document state.
|
`static/js/documentLibrary.js` owns local library state after archive/delete actions, including total counts and language chips. Server route truth still owns durable document state.
|
||||||
|
|
||||||
`static/js/document.js` owns the browser document editor, a sanitized rich-text editing view and Markdown source/preview. Preview rendering applies code highlighting when highlight.js is present, renders Mermaid diagrams when the Mermaid runtime is available, refreshes after AI edits, and discards pending AI diffs before switching the active document.
|
`static/js/document.js` owns the browser document editor and markdown preview. Preview rendering applies code highlighting when highlight.js is present, renders Mermaid diagrams when the Mermaid runtime is available, refreshes after AI edits, and discards pending AI diffs before switching the active document.
|
||||||
|
|
||||||
Document mutations also happen through agent tools, Codex document routes, email attachment import, and scripts. HTTP and native-agent document writers owner-reserve any internal upload/PDF references before persisting new current content or versions. Native document tool outputs include metadata that the browser can use to open/update the editor if a later stream update is missed. Those callers must preserve document owner, attachment, and version semantics.
|
Document mutations also happen through agent tools, Codex document routes, email attachment import, and scripts. HTTP and native-agent document writers owner-reserve any internal upload/PDF references before persisting new current content or versions. Native document tool outputs include metadata that the browser can use to open/update the editor if a later stream update is missed. Those callers must preserve document owner, attachment, and version semantics.
|
||||||
|
|
||||||
After external/workspace-untrusted context, a proposed document mutation is sealed into an exact approval with document id, current version, content digest, tool content, owner/session, and workspace. Approval continuation re-reads and verifies those fields before consuming the one-use authorization, so an intervening edit cannot apply a stale approved patch to new content.
|
After external/workspace-untrusted context, a proposed document mutation is sealed into an exact approval with document id, current version, content digest, tool content, owner/session, and workspace. Approval continuation re-reads and verifies those fields before consuming the one-use authorization, so an intervening edit cannot apply a stale approved patch to new content.
|
||||||
|
|
||||||
Email draft documents are a first-class document language. Create/update paths detect the `To`/`Subject`/header shape, coerce language to `email`, and preserve protected reply/forward headers such as `In-Reply-To`, `References`, `X-Source-UID`, `X-Source-Folder`, attachment headers, and quoted/original history when model or UI edits replace the draft body. Creating a draft for the same source UID/folder in the same session updates the active draft instead of creating a duplicate.
|
Email draft documents are a first-class document language. Create/update paths
|
||||||
|
detect the `To`/`Subject`/header shape, coerce language to `email`, and preserve
|
||||||
|
protected reply/forward headers such as `In-Reply-To`, `References`,
|
||||||
|
`X-Source-UID`, `X-Source-Folder`, attachment headers, and quoted/original
|
||||||
|
history when model or UI edits replace the draft body. Creating a draft for the
|
||||||
|
same source UID/folder in the same session updates the active draft instead of
|
||||||
|
creating a duplicate.
|
||||||
|
|
||||||
`Document` rows own current content and owner. `DocumentVersion` rows own immutable snapshots. Document access should be owner-filtered, not session-id-only; the session document listing path still needs regression coverage for per-document owner filtering after the session owner check.
|
`Document` rows own current content and owner. `DocumentVersion` rows own immutable snapshots. Document access should be owner-filtered, not session-id-only; the session document listing path still needs regression coverage for per-document owner filtering after the session owner check.
|
||||||
|
|
||||||
@@ -128,7 +163,7 @@ Some non-route tool/script paths can index ownerless or arbitrary directories an
|
|||||||
## Optional And Degraded Behavior
|
## Optional And Degraded Behavior
|
||||||
|
|
||||||
- ChromaDB/FastEmbed are default installed dependencies, but Chroma can be offline or unreachable.
|
- ChromaDB/FastEmbed are default installed dependencies, but Chroma can be offline or unreachable.
|
||||||
- Native Chroma defaults to `localhost:8100`; Docker uses the `chromadb:8000` compose service with `chromadb-data:/data` in all three Compose variants. Pre-fix containers can hold live vectors in their writable layer; preserve/rescue that data before recreation using `docs/chromadb-volume-migration.md`.
|
- Native Chroma defaults to `localhost:8100`; Docker uses the `chromadb:8000` compose service and persistent Chroma storage.
|
||||||
- HTTP embeddings can fall back to FastEmbed; when both lanes exist, lane separation avoids Chroma dimension conflicts.
|
- HTTP embeddings can fall back to FastEmbed; when both lanes exist, lane separation avoids Chroma dimension conflicts.
|
||||||
- MarkItDown is optional for Office/EPUB extraction; chat attachments and personal directory indexing have clear degraded behavior, while direct RAG upload does not share the same extraction path.
|
- MarkItDown is optional for Office/EPUB extraction; chat attachments and personal directory indexing have clear degraded behavior, while direct RAG upload does not share the same extraction path.
|
||||||
- PyMuPDF is optional, unlocks PDF form/render/fill paths, and carries AGPL implications when installed.
|
- PyMuPDF is optional, unlocks PDF form/render/fill paths, and carries AGPL implications when installed.
|
||||||
@@ -151,7 +186,7 @@ Concrete enforcement points include:
|
|||||||
|
|
||||||
Extracted attachment text is currently appended into the user message rather than wrapped as a separate untrusted-context message. That is current behavior and a prompt-injection hardening gap.
|
Extracted attachment text is currently appended into the user message rather than wrapped as a separate untrusted-context message. That is current behavior and a prompt-injection hardening gap.
|
||||||
|
|
||||||
Browser document routes still need explicit bearer-token scope/effective-user policy. Upload endpoints have their own delegated owner/scope gates described below; that contract does not extend to every document/PDF/RAG route.
|
Bearer-token callers are not a scoped document/upload API surface today. Routes that treat token-authenticated users as owners need explicit scope/effective-user policy before they are considered safe token APIs.
|
||||||
|
|
||||||
## Testing Coverage
|
## Testing Coverage
|
||||||
|
|
||||||
@@ -159,26 +194,6 @@ Existing useful coverage includes upload owner scope, upload IDs, upload atomici
|
|||||||
|
|
||||||
Route-level coverage is thinner for document CRUD, PDF import/render/export/fill, direct RAG upload, embedding admin/security behavior, and RAG unavailable states.
|
Route-level coverage is thinner for document CRUD, PDF import/render/export/fill, direct RAG upload, embedding admin/security behavior, and RAG unavailable states.
|
||||||
|
|
||||||
## Preserved Originals And Document Views
|
|
||||||
|
|
||||||
`POST /api/documents/import-docx` requires document privilege, verifies any supplied session owner, stores the original through `UploadHandler`, extracts Markdown, and creates a `docx` document with a `docx_source` upload marker. `GET /api/document/{doc_id}/render-docx` checks document and source-upload visibility, uses Mammoth to return an HTML fragment for client sanitization, and returns 503 when Mammoth is absent. The frontend supplies a paper preview while extracted text remains available for editing/search/model context.
|
|
||||||
|
|
||||||
`GET /api/document/{doc_id}/convert-original/{target}` converts preserved DOCX to PDF or PDF to DOCX directly with host LibreOffice/soffice, after document privilege, ownership, and source-upload checks. It uses a temporary output directory and isolated LibreOffice profile in a worker thread with a 120-second subprocess timeout; it does not convert from extracted Markdown. Missing LibreOffice returns 503, timeout 504, and unsuccessful output 502.
|
|
||||||
|
|
||||||
Markdown documents can open `GET /api/document/{doc_id}/visual-report`, reusing `src.visual_report.generate_visual_report()` after document ownership validation. ZIP export recognizes email drafts as `.eml` and converts their header/body separator into an RFC-style blank line. The frontend document graph now also includes `documentOutline.js` and `documentStats.js`.
|
|
||||||
|
|
||||||
## Editing And Export Boundaries
|
|
||||||
|
|
||||||
Rich-text editing mirrors a textarea and supports formatting, checklists, images and tables through sanitized contenteditable content. Internal markers are removed from export; converting rich text/Markdown into simple DOCX paragraphs is lossy and does not preserve arbitrary Word layout. Browser PDF export uses a script-disabled print iframe with same-origin/modal allowances, waits for fonts/styles/images/math and cleans up afterward; server PDF rendering and preserved-original LibreOffice conversion are separate paths.
|
|
||||||
|
|
||||||
Per-document save queues serialize requests and track edit revisions so intervening edits remain marked dirty; this does not promise that every cached response body preserves a newer in-memory edit. Restoring the editor targets the exact active document. Suggestions apply only when the exact find text is unique, report partial applied/unapplied suggestion IDs and retain unapplied review state; server-saved edits suppress duplicate frontend application. Protected email envelope/history remains distinct from editable draft content. PDF/signed-reply preparation still has late async ownership/account edge cases requiring targeted checks.
|
|
||||||
|
|
||||||
## Upload Authentication And Delegated Scopes
|
|
||||||
|
|
||||||
`routes/upload_routes.py` explicitly bypasses upload ownership/auth-service gates only when auth is disabled, including after accounts were configured. With auth enabled, missing auth manager returns 503, an unconfigured manager returns 401, and missing/request-sentinel identity returns 403. Reads, thumbnails and vision reads/edits use `_verify_upload_access()` before confined path access. Interactive owners may access their uploads; verified interactive admins may access other owners and legacy ownerless uploads. Delegated tokens never inherit that administrator override and require an actual token owner matching a non-null file owner.
|
|
||||||
|
|
||||||
Delegated creation and OCR edits require any of `chat` or `documents:write`; downloads/thumbnails/OCR reads accept any of `chat`, `documents:read`, `documents:write`. Missing/unrelated scopes and invalid token owners fail with 403; owner mismatch/ownerless token access uses 404. POST saves effective owner attribution and clears request-sentinel owners. No-login anonymous uploads can remain null-owned and become interactive-admin-only after auth is enabled. Size/type/filename/path validation remains active in no-login mode; these gates do not replace the global authentication middleware.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Direct RAG upload still needs clearer file-type validation and MarkItDown/PDF extraction parity decisions.
|
- Direct RAG upload still needs clearer file-type validation and MarkItDown/PDF extraction parity decisions.
|
||||||
@@ -186,5 +201,5 @@ Delegated creation and OCR edits require any of `chat` or `documents:write`; dow
|
|||||||
- Chat RAG can remain degraded after startup even if personal routes later initialize the RAG singleton.
|
- Chat RAG can remain degraded after startup even if personal routes later initialize the RAG singleton.
|
||||||
- PyMuPDF-dependent routes do not all share the same optional-runtime helper/error behavior.
|
- PyMuPDF-dependent routes do not all share the same optional-runtime helper/error behavior.
|
||||||
- Signed-reply preparation needs owner-scoped source email account/signature regression coverage.
|
- Signed-reply preparation needs owner-scoped source email account/signature regression coverage.
|
||||||
- Browser document/PDF/RAG routes need explicit bearer-token scope/effective-user policy beyond the upload endpoint contract above.
|
- Document/upload routes need explicit bearer-token scope/effective-user policy.
|
||||||
- User-facing document/PDF/RAG route matrices need more regression coverage for owner denial, admin gates, unavailable services, and degraded optional dependencies.
|
- User-facing document/PDF/RAG route matrices need more regression coverage for owner denial, admin gates, unavailable services, and degraded optional dependencies.
|
||||||
|
|||||||
+18
-35
@@ -1,6 +1,6 @@
|
|||||||
# Email And Contacts
|
# Email And Contacts
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@2e2bb52 | 2026-08-16
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ This spec covers mail and contacts in:
|
|||||||
|
|
||||||
Exactly one default account per owner is enforced as a serialized database transition. Startup normalizes legacy duplicate defaults and installs a unique per-owner default constraint/index; first create, delete/promotion, set-default, demo teardown, and owner rename lock the relevant owner rows and commit atomically. Multi-owner rename acquires locks in canonical order so stale concurrent writers fail closed.
|
Exactly one default account per owner is enforced as a serialized database transition. Startup normalizes legacy duplicate defaults and installs a unique per-owner default constraint/index; first create, delete/promotion, set-default, demo teardown, and owner rename lock the relevant owner rows and commit atomically. Multi-owner rename acquires locks in canonical order so stale concurrent writers fail closed.
|
||||||
|
|
||||||
`routes.email.email_helpers` owns (flat imports remain compatibility aliases):
|
`routes.email_helpers` owns:
|
||||||
|
|
||||||
- account owner assertions and config fallback order;
|
- account owner assertions and config fallback order;
|
||||||
- IMAP/SMTP connection helpers and related transport utilities;
|
- IMAP/SMTP connection helpers and related transport utilities;
|
||||||
@@ -57,9 +57,11 @@ Email owner semantics are route-local and compatibility-sensitive:
|
|||||||
- `routes.email_helpers._require_auth()` returns `""` in `AUTH_ENABLED=false` mode, rejects configured auth with no user, and only tolerates first-run anonymous loopback fallback.
|
- `routes.email_helpers._require_auth()` returns `""` in `AUTH_ENABLED=false` mode, rejects configured auth with no user, and only tolerates first-run anonymous loopback fallback.
|
||||||
- Empty owner is treated as single-user compatibility: account-ownership assertions no-op, default/first-enabled account fallback can be global, and email cache clauses include `owner = '' OR owner IS NULL`.
|
- Empty owner is treated as single-user compatibility: account-ownership assertions no-op, default/first-enabled account fallback can be global, and email cache clauses include `owner = '' OR owner IS NULL`.
|
||||||
- Non-empty owners scope account/config/cache queries. Legacy ownerless account
|
- Non-empty owners scope account/config/cache queries. Legacy ownerless account
|
||||||
rows are visible to an authenticated owner only when the row's IMAP username or from-address matches that owner, so old unowned rows do not become global cross-user accounts in configured multi-user deployments.
|
rows are visible to an authenticated owner only when the row's IMAP username
|
||||||
|
or from-address matches that owner, so old unowned rows do not become global
|
||||||
|
cross-user accounts in configured multi-user deployments.
|
||||||
|
|
||||||
`routes.email.email_routes` owns the HTTP mail surface:
|
`routes.email_routes` owns the HTTP mail surface:
|
||||||
|
|
||||||
- account CRUD, test, default, and masked config reads;
|
- account CRUD, test, default, and masked config reads;
|
||||||
- Google OAuth authorize/callback for Workspace and .edu Gmail-style accounts;
|
- Google OAuth authorize/callback for Workspace and .edu Gmail-style accounts;
|
||||||
@@ -89,7 +91,7 @@ IMAP helpers quote mailbox names, raise the Python IMAP line cap for large messa
|
|||||||
|
|
||||||
Scheduled email rows live in `data/scheduled_emails.db` and are owner-scoped. Scheduled send times are normalized before storage.
|
Scheduled email rows live in `data/scheduled_emails.db` and are owner-scoped. Scheduled send times are normalized before storage.
|
||||||
|
|
||||||
`routes.email.email_pollers` owns the scheduled-send poller and single-shot/task/CLI automation passes. Before SMTP work, each poller atomically claims a due row with a conditional `pending` to `sending` update; concurrent in-process/CLI pollers that lose the claim skip the row instead of sending a duplicate. Only the scheduled-send poller starts in-process by default when `ODYSSEUS_INPROCESS_POLLERS` allows it; Docker forwards that gate. Background email automation can also consult the foreground activity gate so auto actions do not compete with active browser/model work. Native cron/systemd can drive one-shot pollers through `scripts/odysseus-mail`.
|
`routes.email_pollers` owns the scheduled-send poller and single-shot/task/CLI automation passes. Before SMTP work, each poller atomically claims a due row with a conditional `pending` to `sending` update; concurrent in-process/CLI pollers that lose the claim skip the row instead of sending a duplicate. Only the scheduled-send poller starts in-process by default when `ODYSSEUS_INPROCESS_POLLERS` allows it; Docker forwards that gate. Background email automation can also consult the foreground activity gate so auto actions do not compete with active browser/model work. Native cron/systemd can drive one-shot pollers through `scripts/odysseus-mail`.
|
||||||
|
|
||||||
Manual and scheduled summaries use the shared LLM adapter and owner-scoped cache instead of constructing provider calls locally. Scheduled summaries use background fallback policy and yield to foreground work; provider exception text is shaped before it can reach the browser.
|
Manual and scheduled summaries use the shared LLM adapter and owner-scoped cache instead of constructing provider calls locally. Scheduled summaries use background fallback policy and yield to foreground work; provider exception text is shaped before it can reach the browser.
|
||||||
|
|
||||||
@@ -105,9 +107,9 @@ Transport degraded behavior:
|
|||||||
|
|
||||||
## Caching And Staleness
|
## Caching And Staleness
|
||||||
|
|
||||||
Email list/read behavior uses short route caches, longer read caches, capped warm prefetch, and owner/account-aware pool/cache keys. The frontend email library has bounded in-memory and local caches, cache-buster refreshes, scheduled/search cache exclusions and stale rows on refresh failure. A cached early return is not a universal stale-while-revalidate guarantee.
|
Email list/read behavior uses short route caches, longer read caches, capped warm prefetch, and owner/account-aware pool/cache keys. The frontend email library has its own session SWR cache, cache-buster refreshes, scheduled/search cache exclusions, and stale-row behavior when refresh fails.
|
||||||
|
|
||||||
Opening an unread message is one authoritative backend IMAP operation. The read route fetches/parses the message and applies `\Seen` over the same connection; cached bodies still await one UID STORE, read-only mailboxes serve content without claiming a mark, and STORE failure returns the body with explicit failure state rather than caching a false read. Inbox/library clients deduplicate opens and carry mailbox context with selected request-generation guards. Those guards are not universal: some detached/inline completions still use mutable account/folder state, so late-response safety must be assessed per path.
|
Opening an unread message is one authoritative backend IMAP operation. The read route fetches/parses the message and applies `\Seen` over the same connection; cached bodies still await one UID STORE, read-only mailboxes serve content without claiming a mark, and STORE failure returns the body with explicit failure state rather than caching a false read. Inbox/library clients deduplicate opens, carry immutable mailbox context, and ignore late responses after account, folder, or message changes.
|
||||||
|
|
||||||
Library prewarm runs only while genuinely idle, as one bounded single-flight request for the default or last-used enabled account and initial page. Visible foreground work, panel lifecycle, account changes, or explicit reads cancel or join it so delayed duplicate IMAP work cannot escape the idle gate.
|
Library prewarm runs only while genuinely idle, as one bounded single-flight request for the default or last-used enabled account and initial page. Visible foreground work, panel lifecycle, account changes, or explicit reads cancel or join it so delayed duplicate IMAP work cannot escape the idle gate.
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ List/read route caches are owner/account-aware. Helper-side summary, AI-reply, t
|
|||||||
|
|
||||||
## Attachments And Signed Replies
|
## Attachments And Signed Replies
|
||||||
|
|
||||||
Compose uploads live under `ODYSSEUS_MAIL_ATTACHMENTS_DIR`; missing staged files are skipped with warnings. Attachment-to-document supports PDF, DOCX, TXT, and MD. DOCX attachment-to-document extraction uses MarkItDown first; optional LibreOffice conversion has a 60-second bound and can supply a PDF preview, otherwise extracted Markdown remains available; PDF form/open-in-doc flows can depend on optional PyMuPDF.
|
Compose uploads live under `ODYSSEUS_MAIL_ATTACHMENTS_DIR`; missing staged files are skipped with warnings. Attachment-to-document supports PDF, DOCX, TXT, and MD. DOCX depends on `python-docx`; PDF form/open-in-doc flows can depend on optional PyMuPDF.
|
||||||
|
|
||||||
Email attachment-as-document flows stamp `Document.source_email_*` provenance. `GET /api/email/attachments-download/{uid}` builds an owner-scoped ZIP of visible non-signature attachments using safe names. `compose-from-odysseus` and `compose-from-odysseus-zip` can stage owner-visible documents and gallery images as compose uploads, preserving legacy session fallback only where the source object remains visible to the owner. `prepare-signed-reply` verifies document ownership, reconstructs reply headers, flattens/stages signed PDFs as compose uploads, and leaves final send/draft review to the compose flow.
|
Email attachment-as-document flows stamp `Document.source_email_*` provenance. `GET /api/email/attachments-download/{uid}` builds an owner-scoped ZIP of visible non-signature attachments using safe names. `compose-from-odysseus` and `compose-from-odysseus-zip` can stage owner-visible documents and gallery images as compose uploads, preserving legacy session fallback only where the source object remains visible to the owner. `prepare-signed-reply` verifies document ownership, reconstructs reply headers, flattens/stages signed PDFs as compose uploads, and leaves final send/draft review to the compose flow.
|
||||||
|
|
||||||
@@ -133,7 +135,12 @@ When the email reader is active, browser chat sends selected-message metadata. `
|
|||||||
|
|
||||||
`mcp_servers/email_server.py` exposes email tools for MCP/agent use. It has its own account discovery, IMAP/SMTP, attachment, cache, and send paths, but account visibility now mirrors the HTTP owner policy. The active owner comes from a hidden `_odysseus_owner` argument when the caller provides one, or from `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`. If any enabled account is owner-scoped and no current/configured owner exists, email MCP returns an owner-scope error instead of listing global accounts.
|
`mcp_servers/email_server.py` exposes email tools for MCP/agent use. It has its own account discovery, IMAP/SMTP, attachment, cache, and send paths, but account visibility now mirrors the HTTP owner policy. The active owner comes from a hidden `_odysseus_owner` argument when the caller provides one, or from `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`. If any enabled account is owner-scoped and no current/configured owner exists, email MCP returns an owner-scope error instead of listing global accounts.
|
||||||
|
|
||||||
MCP email account filtering includes owner-owned rows and legacy ownerless rows whose mailbox/from-address matches the owner. Confirmation-first `send_email` resolves the selected account before stashing an `agent_draft`, so drafts cannot be staged against another owner's account. MCP-created draft documents use the resolved hidden/configured owner when available, with `ODYSSEUS_DOCUMENT_OWNER` and single-admin fallback only as document-visibility compatibility.
|
MCP email account filtering includes owner-owned rows and legacy ownerless rows
|
||||||
|
whose mailbox/from-address matches the owner. Confirmation-first `send_email`
|
||||||
|
resolves the selected account before stashing an `agent_draft`, so drafts cannot
|
||||||
|
be staged against another owner's account. MCP-created draft documents use the
|
||||||
|
resolved hidden/configured owner when available, with `ODYSSEUS_DOCUMENT_OWNER`
|
||||||
|
and single-admin fallback only as document-visibility compatibility.
|
||||||
|
|
||||||
MCP email send behavior is confirmation-first by default: `send_email` and reply send paths stash a `scheduled_emails` row with `status='agent_draft'` when `agent_email_confirm` is true, and browser routes expose pending drafts for approval or cancellation. Separate MCP draft tools create Odysseus compose documents for user review without sending.
|
MCP email send behavior is confirmation-first by default: `send_email` and reply send paths stash a `scheduled_emails` row with `status='agent_draft'` when `agent_email_confirm` is true, and browser routes expose pending drafts for approval or cancellation. Separate MCP draft tools create Odysseus compose documents for user review without sending.
|
||||||
|
|
||||||
@@ -154,7 +161,7 @@ Contact runtime behavior:
|
|||||||
- the native contacts CLI is CardDAV-oriented and does not fully match web JSON fallback behavior;
|
- the native contacts CLI is CardDAV-oriented and does not fully match web JSON fallback behavior;
|
||||||
- agent contact tools reuse helper functions in-process because the HTTP routes require browser/admin auth.
|
- agent contact tools reuse helper functions in-process because the HTTP routes require browser/admin auth.
|
||||||
|
|
||||||
HTTP contact administration remains admin-gated, but in-process helpers have owner-aware local rows and per-owner caches. Named owners see their own rows plus legacy ownerless contacts; synthetic `sft_` owners do not see ownerless rows and bypass CardDAV. This compatibility does not establish a general cross-user sharing API.
|
Contacts are global admin-only data today. There is no per-user contact sharing model unless a future spec defines one.
|
||||||
|
|
||||||
## Security Policy
|
## Security Policy
|
||||||
|
|
||||||
@@ -170,7 +177,7 @@ Known security policy details:
|
|||||||
- Google OAuth state and callback owner checks are part of the account-boundary contract;
|
- Google OAuth state and callback owner checks are part of the account-boundary contract;
|
||||||
- scheduled emails must remain owner-scoped;
|
- scheduled emails must remain owner-scoped;
|
||||||
- email pre-retrieval contacts context is allowed only for admin/single-user situations;
|
- email pre-retrieval contacts context is allowed only for admin/single-user situations;
|
||||||
- HTTP and MCP attachment extraction share `src.mail_attachment_paths.attachment_scope_dir()` for hashed owner/account/folder/message directories; remaining attachment consumers still need end-to-end scope/path coverage.
|
- MCP attachment downloads need route-level path-containment parity; current MCP paths are separate from the HTTP compose/attachment helper path.
|
||||||
|
|
||||||
CardDAV credentials and URLs are security-sensitive. CardDAV URL setup and derived href writes/deletes pass through outbound URL validation; absolute hrefs from a CardDAV server are constrained back to the configured origin before credentials are reused. CardDAV passwords in settings are encrypted and masked on read; environment-sourced legacy password values are used as supplied.
|
CardDAV credentials and URLs are security-sensitive. CardDAV URL setup and derived href writes/deletes pass through outbound URL validation; absolute hrefs from a CardDAV server are constrained back to the configured origin before credentials are reused. CardDAV passwords in settings are encrypted and masked on read; environment-sourced legacy password values are used as supplied.
|
||||||
|
|
||||||
@@ -189,30 +196,6 @@ Existing coverage includes header/envelope/IMAP/SMTP behavior, serialized defaul
|
|||||||
|
|
||||||
Route-level and duplicate-path coverage is still thin for email list/read/search/mutations, account CRUD/security outside the OAuth path, send/draft security, attachments, scheduled-poller failures, contacts admin/CardDAV routes, MCP account/scope behavior, CardDAV degraded mode, and executable frontend behavior.
|
Route-level and duplicate-path coverage is still thin for email list/read/search/mutations, account CRUD/security outside the OAuth path, send/draft security, attachments, scheduled-poller failures, contacts admin/CardDAV routes, MCP account/scope behavior, CardDAV degraded mode, and executable frontend behavior.
|
||||||
|
|
||||||
## Mail Safety And New Library Flows
|
|
||||||
|
|
||||||
Mail connection helpers resolve host addresses once and connect only to policy-approved addresses through `src.url_safety.connect_outbound_tcp()`, retaining hostname TLS verification. Link-local, non-loopback reserved, multicast and unspecified destinations are denied. IPv6 loopback and IPv4-mapped loopback follow the private-address policy rather than unconditional reserved-address denial. Exactly `64:ff9b::/96` is decoded to its IPv4 target and evaluated strictly, including all DNS answers; network-specific NAT64 prefixes are not inferred. Private/loopback/shared destinations are denied for ordinary multi-user owners, allowed for admins/auth-disabled operators, and configurable with `EMAIL_ALLOW_PRIVATE_IPS=true`; `EMAIL_BLOCK_PRIVATE_IPS=true` takes precedence and denies them for everyone.
|
|
||||||
|
|
||||||
The library adds owner-scoped unread-state, attachment metadata caches, bulk attachment ZIP, flagged-message actions, translation, and reviewed unsubscribe flows. `/unsubscribe/scan` reads newsletter/spam candidates; `/unsubscribe/execute` refetches the chosen message and supports mailto unsubscribe by sending through the selected account. HTTP unsubscribe links remain a manual-open path. `/unsubscribe/cleanup` moves reviewed candidates to Junk/Trash and can expand a sender's unsubscribe-header messages. Scanning alone does not send or move mail.
|
|
||||||
|
|
||||||
`src.mail_attachment_paths` hashes the full owner/account/folder/UID scope into an extraction directory so legacy ownerless paths cannot reuse another mailbox's staged attachment. `src.email_attachment_text` centralizes readable attachment extraction. Email calendar pollers apply ICS revisions through `src.email_calendar_import`; invitation state and reminder semantics are described in `calendar-tasks-notes.md`. `src.email_task_intent` supplies email-task intent helpers, and `src.email_reply_stream` handles reply-stream text behavior.
|
|
||||||
|
|
||||||
## Mailbox State And Delivery Boundaries
|
|
||||||
|
|
||||||
MCP email binds owner and active session in request-local contexts and resets them on exit. Cross-account list caching is short-lived (20 seconds) and listing can use four concurrent workers; it does not promise single-flight freshness. Nonempty local header-index results can suppress a live lookup. MCP sent-time ISO ranges normalize missing zones to UTC and use an inclusive start/exclusive end; browser date-only received-time filters include the end date. Cached/fixture paths do not uniformly apply every date filter. Search pills combine terms; index fallback and at-most-six attachment search terms do not guarantee complete provider search.
|
|
||||||
|
|
||||||
MCP bulk UID move fallback checks command statuses and verifies absence from the source; this is not proof of destination delivery. Browser and MCP single-message move paths instead rely on MOVE or COPY/STORE/expunge acknowledgements without a post-move source read.
|
|
||||||
|
|
||||||
Browser bulk delete requires a successful native MOVE or recoverable Trash COPY before removing originals. COPY failure retains source/index; fallback STORE batches only copied UIDs for selective RFC 4315 UID EXPUNGE, never mailbox-wide EXPUNGE. Index rows are removed only after confirmed deletion. Failed selective expunge retains the index and attempts to remove newly added `\Deleted` flags while preserving observed pre-existing flags; disconnects can prevent rollback, and a lost acknowledgement is not proof the source survived. Servers without selective expunge can therefore report failure despite a Trash copy. Responses distinguish confirmed deleted and failed UIDs, total failure, and partial success; source and Trash list caches invalidate in `finally` when any deletion was confirmed. Single-message paths retain their separate behavior.
|
|
||||||
|
|
||||||
Draft replacement appends the new mailbox draft before removing the old one. Sent draft cleanup follows successful Sent append; SMTP, IMAP and local database state are not one transaction. Scheduled send claims prevent concurrent claim winners, not exactly-once SMTP across a crash after send and before checkpoint.
|
|
||||||
|
|
||||||
Composer sends first save a recovery mailbox draft, then use `wait_for_delivery` and preserve exact draft UID/folder for delivery cleanup. Failures/cancellation retain recoverable draft state. AI reply application checks generation/document/body snapshots and intervening manual input before overwriting. Confirmation-first MCP pending drafts are a distinct workflow. Reply-by-Message-ID resolves owner/account/UID context rather than trusting a global id; recursive fallback work is bounded and gathered on cancellation. Reply generation disables thinking, uses bounded initial output and one larger retry; this does not guarantee provider delivery.
|
|
||||||
|
|
||||||
Unsubscribe scanning inspects at most the newest 500 candidates and deduplicates senders; bulk operations are bounded and can partially fail. A sent mailto attempt can move its source to Trash, while pending confirmation leaves it untouched; successful send-attempt bookkeeping is not confirmation that a remote subscription ended. Blocked-sender JSON is owner-keyed but not transactionally coupled to mailbox changes. Bulk attachment ZIP is capped at 250 messages in batches of twenty, skips failed messages, uses safe names and assembles in memory.
|
|
||||||
|
|
||||||
Reader HTML snapshots use inert parsing/sanitization and sandbox/CSP constraints; blocked network in browser tests is a test condition rather than universal runtime network isolation. Attachment-only/media-only bodies remain meaningful; a thumbnail `img.src` is not itself a URL sanitizer. Local reminder metadata can identify folder/UID without an account and is a narrower compatibility path than canonical mailbox identity. Contact cache success/failure windows and shared refresh locking do not turn a CardDAV outage into local-write success.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Owner-keyed cache policy still needs an explicit decision for thread boundaries, plus continued migration/query audits for every email side table.
|
- Owner-keyed cache policy still needs an explicit decision for thread boundaries, plus continued migration/query audits for every email side table.
|
||||||
|
|||||||
+16
-38
@@ -1,6 +1,6 @@
|
|||||||
# Frontend
|
# Frontend
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -11,14 +11,14 @@ This spec covers the current browser app in:
|
|||||||
- `static/index.html`;
|
- `static/index.html`;
|
||||||
- `static/login.html`;
|
- `static/login.html`;
|
||||||
- `static/app.js`;
|
- `static/app.js`;
|
||||||
- ordered stylesheets under `static/css/`;
|
- `static/style.css`;
|
||||||
- `static/js/*.js` and `static/js/*/*.js`;
|
- `static/js/*.js` and `static/js/*/*.js`;
|
||||||
- vendor libraries under `static/lib/*`;
|
- vendor libraries under `static/lib/*`;
|
||||||
- custom fonts and static assets under `static/fonts/*`;
|
- custom fonts and static assets under `static/fonts/*`;
|
||||||
- `static/sw.js` and `static/manifest.json`;
|
- `static/sw.js` and `static/manifest.json`;
|
||||||
- frontend-oriented tests in `tests/*_js.py`, `tests/*.mjs`, `tests/bombadil-spec.ts`, static DOM/CSS/source-shape tests, and app/static tests such as `tests/test_app_static_mime.py`.
|
- frontend-oriented tests in `tests/*_js.py`, `tests/*.mjs`, `tests/bombadil-spec.ts`, static DOM/CSS/source-shape tests, and app/static tests such as `tests/test_app_static_mime.py`.
|
||||||
|
|
||||||
`/backgrounds` remains registered in `app.py` but targets the absent `static/backgrounds.html`. Authentication middleware also gates it when auth is enabled, despite its handler docstring describing a public prototype page. This is a known route/static gap; the page is not a working frontend surface at the checked revision.
|
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
|
||||||
|
|
||||||
`static/manifest.json`, `static/index.html`, and `static/login.html` reference first-party PWA icon files under `static/icons/` (192px, 512px, and maskable). At runtime, route-specific manifests can also be generated as Blob URLs when supported, and per-route favicon links use generated inline SVG.
|
`static/manifest.json`, `static/index.html`, and `static/login.html` reference first-party PWA icon files under `static/icons/` (192px, 512px, and maskable). At runtime, route-specific manifests can also be generated as Blob URLs when supported, and per-route favicon links use generated inline SVG.
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ This spec covers the current browser app in:
|
|||||||
|
|
||||||
## Runtime Shape
|
## Runtime Shape
|
||||||
|
|
||||||
The browser runtime is a static native-ES-module SPA served by FastAPI, without a runtime Node/build dependency. Incremental maintained sources now live under `frontend/src/`, with explicit source-to-deployment mappings in `frontend/modules.json`; the map publishes `shared/actionMenuOrder.js`, the Notes feature graph and shared confetti to committed `static/js/` outputs. `scripts/build_frontend.py` publishes/checks those bytes during development, never application startup. Legacy modules remain authored under `static/`; there is no Vite/React/TypeScript bundle.
|
The frontend is a raw static SPA served by FastAPI. There is no Vite, React, TypeScript, bundler, or generated build output.
|
||||||
|
|
||||||
`app.py` owns:
|
`app.py` owns:
|
||||||
|
|
||||||
@@ -49,11 +49,7 @@ The browser runtime is a static native-ES-module SPA served by FastAPI, without
|
|||||||
|
|
||||||
The two first-paint Fira Code faces are preloaded so the shell does not wait for later CSS discovery. `static/js/startupShell.js` lets the visible shell initialize before session loading completes; session/transcript hydration is deferred and coordinated by `static/js/sessions.js` plus history/session routes rather than blocking first paint.
|
The two first-paint Fira Code faces are preloaded so the shell does not wait for later CSS discovery. `static/js/startupShell.js` lets the visible shell initialize before session loading completes; session/transcript hydration is deferred and coordinated by `static/js/sessions.js` plus history/session routes rather than blocking first paint.
|
||||||
|
|
||||||
Exact script URL identity matters. Versioned script tags, unversioned imports, and service-worker precache entries must stay aligned. `static/sw.js` deliberately separates first-paint `PRECACHE` from lazy `PANEL_PRECACHE`; the latter contains the image-editor module graph so an editor never opened online can still open offline. The Notes feature graph and shared confetti are explicitly in `PRECACHE`, together with their exact legacy helper URLs. KaTeX scripts/styles/fonts are also precached. Current service-worker coverage is not a generated full module-graph manifest, so changes still need direct verification.
|
Exact script URL identity matters. Versioned script tags, unversioned imports, and service-worker precache entries must stay aligned. `static/sw.js` deliberately separates first-paint `PRECACHE` from lazy `PANEL_PRECACHE`; the latter currently contains the image-editor module graph so an editor never opened online can still open offline. KaTeX scripts/styles/fonts are also precached. Current service-worker coverage is not a generated full module-graph manifest, so changes still need direct verification.
|
||||||
|
|
||||||
`static/style.css` has been removed. `static/index.html` loads numbered `static/css/00-tokens.css` through `17-research.css` in order, followed by the three domain panel stylesheets. `static/sw.js` precaches those exact versioned URLs; cascade order remains part of behavior.
|
|
||||||
|
|
||||||
New extracted owners include `static/js/turnRendering.js` for live/canonical turn DOM reconciliation, `backgroundToolJobs.js` for deduplicated background deliveries and research status cards, `emailReplyStream.js` for email reply streams, and `chatgptSubscriptionUsage.js` for account usage UI. Settings panel modules now also include `agent.js`, `api.js`, `imageModels.js`, `speech.js`, and `writingStyle.js`.
|
|
||||||
|
|
||||||
## Security Policy
|
## Security Policy
|
||||||
|
|
||||||
@@ -80,7 +76,7 @@ Storage/secrets policy:
|
|||||||
`static/sw.js` owns PWA cache behavior:
|
`static/sw.js` owns PWA cache behavior:
|
||||||
|
|
||||||
- API and non-GET requests are bypassed;
|
- API and non-GET requests are bypassed;
|
||||||
- only `/` navigation uses network-first with cached-shell fallback; other navigation URLs are not replaced with the app shell;
|
- root navigation uses stale-while-revalidate;
|
||||||
- JS/CSS use network-first behavior;
|
- JS/CSS use network-first behavior;
|
||||||
- other static assets use cache-first with background refresh;
|
- other static assets use cache-first with background refresh;
|
||||||
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
|
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
|
||||||
@@ -101,7 +97,7 @@ Current major frontend areas include:
|
|||||||
- image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`;
|
- image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`;
|
||||||
- gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers.
|
- gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers.
|
||||||
|
|
||||||
`static/js/emailLibrary.js` is a compatibility wrapper over one unversioned package entry and explicit leaves for reader, menus, attachments, reply, settings and unsubscribe behavior; the final wrapper has ten named exports. `tests/test_email_library_module_graph_js.py` compares the unversioned public surface, imports immediate package modules in synthetic browser shells and checks quoted service-worker URLs. It does not prove every callback, nested module, load order or offline behavior. `tests/helpers/js_modules.py` uses immediate entry-first file enumeration and column-zero source extraction; its package-name binding regex is not lexical-scope analysis.
|
`static/js/emailLibrary/` is the one JS package with module-graph coverage: `tests/test_email_library_module_graph_js.py` pins the wrapper's public surface against the entry module's exports, evaluates every module on its own in a browser so an import cycle cannot hide a temporal-dead-zone read, and requires each module to be in the `sw.js` precache. Assertions about its source go through `tests/helpers/js_modules.py`, which reads the whole package, for the same reason `tests/helpers/stylesheets.py` reads the whole cascade.
|
||||||
|
|
||||||
Coordinator ownership:
|
Coordinator ownership:
|
||||||
|
|
||||||
@@ -120,15 +116,9 @@ Recent browser behavior contracts include mobile chat Enter inserting newlines w
|
|||||||
|
|
||||||
The Settings finder and navigation are registry-backed, hide admin-only destinations from non-admin users, lazy-load admin panels, and keep the registry synchronized with DOM panels. Email OAuth connect preserves SMTP security and reopens the settings surface; unread message opens use one authoritative backend read/mark-seen request with stale-response guards; email-library prewarm is idle-only, single-flight, bounded to the initial page, and cancelled around visible foreground work.
|
The Settings finder and navigation are registry-backed, hide admin-only destinations from non-admin users, lazy-load admin panels, and keep the registry synchronized with DOM panels. Email OAuth connect preserves SMTP security and reopens the settings surface; unread message opens use one authoritative backend read/mark-seen request with stale-response guards; email-library prewarm is idle-only, single-flight, bounded to the initial page, and cancelled around visible foreground work.
|
||||||
|
|
||||||
## Notes Feature Ownership
|
|
||||||
|
|
||||||
`frontend/src/features/notes/index.js` composes one canonical Notes instance from domain rules/recurrence, application operations/state, infrastructure ports and UI factories. `frontend/modules.json` publishes its source modules byte-for-byte under `static/js/features/notes/` and shared confetti under `static/js/shared/ui/`. `static/js/notes.js` is the compatibility facade: it reexports the canonical methods/default, assigns `window.notesModule` and starts idempotent background reminders. Versioned/unversioned facade imports converge on the unversioned canonical entry. `static/app.js` retains the versioned facade caller; Compare voting imports and reexports shared confetti rather than owning it.
|
|
||||||
|
|
||||||
State mutations belong to `application/state.js`; UI gets live read-only views, and operation writes unwrap nested views to keep stored note data cloneable. The composition injects HTTP, persistence, clock/scheduler, notification and shared platform callbacks. Panel disposal releases owned portals, document/window listeners, gesture state and masonry observers; detached/closed-panel checks prevent late completions from recreating observers. Tablet reorder rewiring replaces stale binding sentinels. Reminder scheduling is page-owned and survives close/minimize; custom reminder date initialization uses DOM value assignment rather than HTML interpolation. Domain-specific drafts/reminder/selection contracts and remaining races are in `calendar-tasks-notes.md`.
|
|
||||||
|
|
||||||
## UI Policy
|
## UI Policy
|
||||||
|
|
||||||
- New runtime code remains native browser ES modules; manifest-selected maintained sources use the development publication/check step without adding a startup build.
|
- New code must run as browser ES modules without a build step.
|
||||||
- Reuse existing CSS variables, modal/window patterns, icon style, storage helpers, and route conventions.
|
- Reuse existing CSS variables, modal/window patterns, icon style, storage helpers, and route conventions.
|
||||||
- Custom font handling includes bundled OpenDyslexic assets plus user-supplied fonts exposed through `/api/fonts/custom`; font and text-size settings must stay coordinated between settings UI, theme helpers, and CSS variables.
|
- Custom font handling includes bundled OpenDyslexic assets plus user-supplied fonts exposed through `/api/fonts/custom`; font and text-size settings must stay coordinated between settings UI, theme helpers, and CSS variables.
|
||||||
- Avoid relying on stale module summaries.
|
- Avoid relying on stale module summaries.
|
||||||
@@ -148,37 +138,25 @@ State mutations belong to `application/state.js`; UI gets live read-only views,
|
|||||||
|
|
||||||
Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution.
|
Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution.
|
||||||
|
|
||||||
`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a stylesheet cascade restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover.
|
`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a `static/style.css` restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover.
|
||||||
|
|
||||||
Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling.
|
Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling.
|
||||||
|
|
||||||
Areas that still need explicit behavior verification include:
|
Missing coverage includes:
|
||||||
|
|
||||||
- SPA route/static auth and no-cache headers;
|
- SPA route/static auth and no-cache headers;
|
||||||
- CSP header contents and nonce injection for `/` and `/login`;
|
- CSP header contents and nonce injection for `/` and `/login`;
|
||||||
- service-worker API/non-GET bypass and cache strategy;
|
- service-worker API/non-GET bypass and cache strategy;
|
||||||
- complete service-worker precache membership versus HTML scripts/modules: stylesheet parity is covered by `tests/test_static_stylesheet_manifest.py`, and `tests/test_frontend_module_version_parity.py` checks selected app/index/SW versions plus regex-discovered JS/index URL identities, but neither proves complete graph evaluation;
|
- service-worker precache versus `index.html` script/module tags, including query strings (stylesheet links and their `?v=` strings are covered by `tests/test_static_stylesheet_manifest.py`; script and module tags are not);
|
||||||
- ongoing manifest/icon reference drift; `sw.js` now explicitly precaches the default manifest and three PWA icons;
|
- ongoing manifest/icon reference drift;
|
||||||
- complete legacy module graph/load-order behavior beyond maintained-source architecture gates and the email-library browser graph;
|
- module graph/load-order validation;
|
||||||
- degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path.
|
- degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path.
|
||||||
|
|
||||||
## Rendering, Provider UI And Static Checks
|
|
||||||
|
|
||||||
`turnRendering.js` reconciles canonical/live ownership with turn replacement metadata while keeping tool activity. Compact corrected/final events can replace earlier prose; the server completion gate can buffer answer deltas, so this is not a promise of immediate streaming. Ordinary ask-user choices have 1–3 keyboard shortcuts; tool approval cards are excluded. Active document state restores the exact chat-bound document; editor rich-only font controls and stats portal z-order are independent of chat tool authority. The research map SVG has an image role and accessible name, without proving complete accessibility.
|
|
||||||
|
|
||||||
Usage ledgers use nested Maps to preserve arbitrary legacy keys as data, while fresh writes require a nonempty session id and reject reserved session/run keys before accounting. JSON conversion preserves wire shape; metrics with a run id replace that run's previous value, while legacy writes without one add to aggregate totals and use per-object recorded/pending guards. Web Locks are used when available and bounded entries fold into totals, so evicted run replay is not forever deduplicated. Admin provider cards hide ChatGPT/recognized Featherless base URLs; optional fingerprints and displayed version/source commit are informational rather than byte attestation. Featherless catalog search is debounced/paginated with bounded results; optimistic pin/tool-mode PATCH saves and load-more handling do not guarantee acknowledged persistence or universal stale-response cancellation. Collapsed account usage cards retain loaded DOM, including errors, rather than proving fresh telemetry.
|
|
||||||
|
|
||||||
Markdown SVG uses an empty-sandbox, no-referrer iframe and restrictive CSP; title extraction is flat escaped text rather than parent-DOM parsing. Fenced and complete raw SVG are separate from generic HTML fences, with bounded aspect ratio. This does not confer the different permissions used by document print/run iframes. Remote mail rendering and image thumbnail assignment retain their own sanitizer/network boundaries.
|
|
||||||
|
|
||||||
Stylesheet helpers read the shell-ordered complete app cascade, exclude vendored libraries and share cache-token checks. Recursive lexical hygiene rejects deleted-monolith references with bounded exceptions; comment-stripped keyframe counting catches duplicate global names. Document-source helpers enumerate implementation files and scan braces lexically; quoted import/SW membership and minimum callable export tests are narrower than semantic module execution. The fixed computed-style snapshot covers 676 elements across its configured page/viewport/theme/density cases, freezes animation timing and checks a global rule-swap sensitivity count; it is not arbitrary browser/layout coverage. Manual benchmark HTML and historical screenshots are not newly reproduced acceptance.
|
|
||||||
|
|
||||||
The website has seven hover/focus/tap preview clips, ambient video, failure placeholders and thresholded swipe navigation. Reduced-motion CSS disables selected animation rather than guaranteeing all video stops. Restored branding/PWA assets do not prove license clearance or decoded-image review; the known `/backgrounds` absent-file/auth gap remains.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- The ordered stylesheet cascade and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
|
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
|
||||||
- There is no general build-time type checking or complete legacy script-order validation. `scripts/check_frontend.mjs` checks maintained-source imports, layer boundaries, explicit legacy exceptions, deployment URL/precache identity, and dependency cycles; its inventory mode reports legacy static imports/cycles. These gates cover the manifest-selected maintained graph, not every legacy module. Service-worker checks also cover stylesheet URLs and the email-library package.
|
- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets, and for the `static/js/emailLibrary/` package only.
|
||||||
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
|
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
|
||||||
- `window.*` compatibility bridges remain widespread.
|
- `window.*` compatibility bridges remain widespread.
|
||||||
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
|
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
|
||||||
- Static regressions cover stylesheet references, selected script/module URL identities and maintained-output precache membership; complete legacy graph membership/evaluation and ongoing manifest icon drift still require checks.
|
- The static asset/route manifest regression covers stylesheets referenced by app-owned HTML and `sw.js`; scripts, modules and `manifest.json` icon references are still unverified.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Gallery, Editor, And Media
|
# Gallery, Editor, And Media
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ This spec covers media surfaces in:
|
|||||||
|
|
||||||
- gallery upload, library, album, tag, favorite, ZIP, delete, and saved-project views;
|
- gallery upload, library, album, tag, favorite, ZIP, delete, and saved-project views;
|
||||||
- chat-generated image rendering/edit/delete bubbles;
|
- chat-generated image rendering/edit/delete bubbles;
|
||||||
- agent `generate_image` and owner-scoped `edit_image` tool paths;
|
- agent `generate_image` and stale `edit_image` tool paths;
|
||||||
- MCP image-generation rows/files;
|
- MCP image-generation rows/files;
|
||||||
- image editor AI tools and model endpoint pickers;
|
- image editor AI tools and model endpoint pickers;
|
||||||
- document PDF signing with stored signatures;
|
- document PDF signing with stored signatures;
|
||||||
@@ -51,13 +51,11 @@ Generated media provenance:
|
|||||||
- upload `file_hash` is a separate metadata field;
|
- upload `file_hash` is a separate metadata field;
|
||||||
- generated files live under `data/generated_images`;
|
- generated files live under `data/generated_images`;
|
||||||
- chat image generation writes files and inserts `GalleryImage` rows through `src.ai_interaction`;
|
- chat image generation writes files and inserts `GalleryImage` rows through `src.ai_interaction`;
|
||||||
- MCP image generation stamps the trusted dispatched owner when present, trims empty owner values to null, and can still create ownerless rows/files without that context;
|
- MCP image generation can create ownerless rows/files;
|
||||||
- generated-but-not-yet-imported images can have no gallery row;
|
- generated-but-not-yet-imported images can have no gallery row;
|
||||||
- once a gallery row exists, owner checks decide visibility where the route enforces them.
|
- once a gallery row exists, owner checks decide visibility where the route enforces them.
|
||||||
|
|
||||||
`app.py` owns direct `/api/generated-image/{filename}` serving through `src.generated_images.resolve_generated_image_path()`. It validates filename syntax before ownership and disk probing. With auth enabled, `effective_user()` resolves cookie/API-token owners. A live registry entry is checked first: a matching owner authorizes, while a mismatch returns 404 without database fallback. Only absent registration reaches the gallery lookup: a nonempty row owner must match; a row without an owner instead requires a matching linked-session owner. If that lookup finds no gallery row, it requires a chat metadata reference in an owned session. Unattributed/mismatched files return 404 and database verification failures return 503. Disabled auth skips ownership verification. Confined path/existence checks follow authorization, and responses use `private, no-cache, must-revalidate` plus nosniff headers. Gallery replace/rotate/save/delete/ZIP paths also resolve filenames through a shared generated-image path helper so database filenames cannot escape `data/generated_images`. Replace/rotate/save-over-original flows can mutate bytes under the same filename, so frontend cache busting matters.
|
`app.py` owns direct `/api/generated-image/{filename}` serving through `src.generated_images.resolve_generated_image_path()`. It validates hex-like image/video filenames, rejects path escape and missing files, serves rowless generated files, checks row owner when a row exists, allows null-owner compatibility rows, and uses immutable/nosniff cache headers. Gallery replace/rotate/save/delete/ZIP paths also resolve filenames through a shared generated-image path helper so database filenames cannot escape `data/generated_images`. Replace/rotate/save-over-original flows can mutate bytes under the same filename, so frontend cache busting matters.
|
||||||
|
|
||||||
`src/generated_images.py` owns a lock-protected, process-local one-hour recent-owner registry. Image generation/edit writers in `src/ai_interaction.py` register trusted owners after writing bytes; the image MCP server stamps gallery ownership and registers after commit when an owner exists. Registry entries are ephemeral, not durable gallery ownership, and matching registry access can survive a database outage. Tool dispatch strips caller-reserved attribution keys before injecting verified owner context, including qualified image MCP and direct fallback paths.
|
|
||||||
|
|
||||||
## Image Tools And Providers
|
## Image Tools And Providers
|
||||||
|
|
||||||
@@ -94,7 +92,7 @@ Optional dependency behavior:
|
|||||||
|
|
||||||
## Editor Drafts
|
## Editor Drafts
|
||||||
|
|
||||||
`routes.editor_draft_routes` owns server-backed image editor project payloads. `EditorDraft` rows store name, dimensions, payload JSON, thumbnail, source image, timestamps, and owner.
|
`routes.editor_draft_routes` owns server-backed image editor project payloads. `EditorDraft` rows store title, payload JSON, thumbnail, source image, timestamps, and owner.
|
||||||
|
|
||||||
Frontend editor behavior is split across `static/js/editor/*` and `static/js/galleryEditor.js`: canvas state, layer panel, masks, history, snapping, stroke pipeline, inpaint/rembg/harmonize tools, AI tool runner, model pickers, an AI edit command box that routes natural-language edit requests into existing inpaint/remove/upscale/background/style actions where possible, import wiring, topbar controls, auto-save, resume by draft ID or source image, draft-only open, and cleanup after close. `static/js/panels.js` loads this module graph on first editor use, shares concurrent imports, retries failed loads, and `static/sw.js` keeps the lazy graph in a separate offline panel precache.
|
Frontend editor behavior is split across `static/js/editor/*` and `static/js/galleryEditor.js`: canvas state, layer panel, masks, history, snapping, stroke pipeline, inpaint/rembg/harmonize tools, AI tool runner, model pickers, an AI edit command box that routes natural-language edit requests into existing inpaint/remove/upscale/background/style actions where possible, import wiring, topbar controls, auto-save, resume by draft ID or source image, draft-only open, and cleanup after close. `static/js/panels.js` loads this module graph on first editor use, shares concurrent imports, retries failed loads, and `static/sw.js` keeps the lazy graph in a separate offline panel precache.
|
||||||
|
|
||||||
@@ -128,11 +126,11 @@ Known boundaries:
|
|||||||
|
|
||||||
- image-generation routes require `can_generate_images`;
|
- image-generation routes require `can_generate_images`;
|
||||||
- image proxy/editor endpoints currently resolve client-selected, DB-selected, or fallback image model endpoints without full owner-scoped endpoint-key policy or uniform outbound revalidation;
|
- image proxy/editor endpoints currently resolve client-selected, DB-selected, or fallback image model endpoints without full owner-scoped endpoint-key policy or uniform outbound revalidation;
|
||||||
- rowless generated files require an owned chat reference or recent registration; null-owner gallery rows require a matching owned session;
|
- generated-file serving allows rowless files and null-owner compatibility rows;
|
||||||
- uploads are byte-limited and extension-gated, with content sniffing available through `UploadHandler.detect_content_type()` when `python-magic`/`libmagic` is installed;
|
- uploads are byte-limited and extension-gated, with content sniffing available through `UploadHandler.detect_content_type()` when `python-magic`/`libmagic` is installed;
|
||||||
- several base64 JSON editor routes accept large decoded image payloads and need route-level size discipline;
|
- several base64 JSON editor routes accept large decoded image payloads and need route-level size discipline;
|
||||||
- gallery DB filenames should be joined through shared generated-media path helpers before filesystem operations;
|
- gallery DB filenames should be joined through shared generated-media path helpers before filesystem operations;
|
||||||
- editor drafts owner-scope rows for identified users and cap serialized payload bytes, but source-gallery ownership and thumbnail size still need separate validation;
|
- editor draft source image IDs, payloads, and thumbnails are owner-scoped by draft owner but do not fully validate source-gallery ownership or payload size;
|
||||||
- emoji proxy constrains codepoint filenames and degrades invalid, unknown, or unreachable SVGs to transparent no-store placeholders, but remote SVG content still deserves security review;
|
- emoji proxy constrains codepoint filenames and degrades invalid, unknown, or unreachable SVGs to transparent no-store placeholders, but remote SVG content still deserves security review;
|
||||||
- visual report Markdown HTML/link/image output needs continued sanitization coverage.
|
- visual report Markdown HTML/link/image output needs continued sanitization coverage.
|
||||||
- `scripts/mlx_image_server.py` pins generation/edit routing to the process-start model and ignores request-selected model names, preventing unauthenticated callers from selecting a local model directory/repository whose model-specific script or bridge would execute.
|
- `scripts/mlx_image_server.py` pins generation/edit routing to the process-start model and ignores request-selected model names, preventing unauthenticated callers from selecting a local model directory/repository whose model-specific script or bridge would execute.
|
||||||
@@ -144,41 +142,24 @@ Known boundaries:
|
|||||||
- Missing generated files are skipped in ZIP downloads; if all are missing, the route returns no files found.
|
- Missing generated files are skipped in ZIP downloads; if all are missing, the route returns no files found.
|
||||||
- Soft delete commits the gallery row state before removing the disk file, so a failed DB write does not orphan a missing image row.
|
- Soft delete commits the gallery row state before removing the disk file, so a failed DB write does not orphan a missing image row.
|
||||||
- AI tagging can fail when disk files are missing.
|
- AI tagging can fail when disk files are missing.
|
||||||
- Static assets revalidate; most editor modules remain static ES modules, while `frontend/modules.json` and the committed generated-output freshness gate govern the manifest-selected native ES modules published by byte-for-byte copying to committed deployment outputs.
|
- Static JS/CSS/HTML assets revalidate because there is no frontend build/versioning.
|
||||||
- Gallery/editor frontend state includes stale-while-revalidate and listener cleanup to avoid stale handlers.
|
- Gallery/editor frontend state includes stale-while-revalidate and listener cleanup to avoid stale handlers.
|
||||||
- `src/tools/image.py` implements `edit_image` in-process: prompt edits call the model adapter, upscale creates a 2x/4x Lanczos copy, and rembg creates a copy when the optional package is installed. It checks exact gallery owner and confined source path; uploaded attachment URIs support prompt edits after owner-aware resolution.
|
- `edit_image` tool schema/implementation currently appears stale against implemented `/api/image/*` and `/api/gallery/*` routes.
|
||||||
|
|
||||||
## Testing Coverage
|
## Testing Coverage
|
||||||
|
|
||||||
Existing tests cover EXIF dimensions, owner-filter helper behavior, direct upload limits, image-generation privilege source shape, sharpen auth, gallery null-user denial, endpoint SSRF/source checks, editor draft payload validation, lazy editor loading/offline precache, MLX request-model pinning, font family derivation, visual-report helper behavior, gallery CLI previews, and selected security regressions.
|
Existing tests cover EXIF dimensions, owner-filter helper behavior, direct upload limits, image-generation privilege source shape, sharpen auth, gallery null-user denial, endpoint SSRF/source checks, editor draft payload validation, lazy editor loading/offline precache, MLX request-model pinning, font family derivation, visual-report helper behavior, gallery CLI previews, and selected security regressions.
|
||||||
|
|
||||||
Route-level coverage is thin for full gallery CRUD/album/tag/download/delete flows, generated-image serving, editor draft owner CRUD, signature owner CRUD, emoji proxy/cache behavior, image-tool degraded responses, optional dependency fallbacks, and full browser editor integration. Focused codec, layer/mask/group, transform, history, worker, and draft-budget regressions are present; this maintenance pass did not execute them.
|
Route-level coverage is thin for full gallery CRUD/album/tag/download/delete flows, generated-image serving, editor draft owner CRUD, signature owner CRUD, emoji proxy/cache behavior, image-tool degraded responses, optional dependency fallbacks, and frontend editor behavior.
|
||||||
|
|
||||||
## Versioned Editor Documents And Budgets
|
|
||||||
|
|
||||||
`static/js/editor/document-codec.js` is the shared project-file/autosave serialization boundary, currently document version 15. It migrates older versions, rejects future versions, validates layer/group/mask/selection and geometry data, and bounds canvas pixels, layer counts, total surfaces, and embedded image data. Masks, adjustments, effects, groups, vector text/shapes, placed layers and view/guide state pass through this codec rather than a second hand-written draft shape, subject to serialization/decode and history limitations below; this is not lossless persistence of every mask/effect property.
|
|
||||||
|
|
||||||
Editor responsibilities now include `document-geometry.js` and layer geometry for canvas transforms, `layer-groups.js`/`layer-selection.js` for grouping/multi-selection, `selection-mask.js` for selections, `text-layer.js`/`shape-layer.js` for vector content, and `adjustment-layer.js`/`effects.js` with worker paths for compositing operations. `history-budget.js` bounds raw ImageData history adaptively; rendering cancellation prevents stale asynchronous results from applying after newer edits.
|
|
||||||
|
|
||||||
`routes/editor_draft_routes.py` uses `EDITOR_DRAFT_MAX_BYTES` both for early rejection of oversized declared Content-Length on writes and for the authoritative serialized-payload byte check. Missing/malformed Content-Length still reaches the latter check after parsing. This cap does not supply source-gallery ownership or a separate thumbnail budget.
|
|
||||||
|
|
||||||
`src/media_ingress.py` separately builds bounded local multimodal attachments for supported image/video/audio/document paths with source hashes, encoding/dimension/frame limits, and artifact metadata. Agent evidence/trace consumers record ingress metadata; it is not the browser upload storage or editor serialization owner.
|
|
||||||
|
|
||||||
## Editor Persistence And Operation Limits
|
|
||||||
|
|
||||||
Codec limits count canvas/surface allocations and embedded strings rather than universally bounding decoded bytes. Failed canvas encoding or image restoration can omit/drop data; effect-mask normalization and history snapshots do not preserve every mask density/feather property. Async capture can span intervening edits, so it is not an immutable atomic pixel snapshot. History deduplicates shared pixel buffers and trims older snapshots to a budget while retaining the newest even when oversized; it does not serialize the entire history into a draft.
|
|
||||||
|
|
||||||
Draft autosave reserves one in-flight capture and uses session/generation checks for assigning returned ids, with PUT404 recreation. Those checks do not cancel an old server request. The current persist chain can clear an earlier error and display Saved after catch/early return; the badge is not a durable-write receipt. Project state uses server draft ids and a compact session descriptor rather than a full browser-sessionStorage payload. Restore can drop failed raster/mask/placed sources and is not a lossless project recovery promise.
|
|
||||||
|
|
||||||
Placed transforms, crop/resize/flip, linked versus document-space masks, grouped effects and rasterization retain distinct geometry paths. Duplicate/merge/copy operations do not guarantee independent preservation of every effect-mask/retained metadata field; raw layer copying differs from flattened canonical output. Pixel/position locks are checked on selected mutation paths rather than proving every control respects every lock. Escape cancellation is operation-specific (including crop/transform/filter prompts), not a universal rollback guarantee. Filter/worker generations prevent selected stale results, but shared intermediate canvases and Quick Edit busy-end polling do not establish atomic pixels or successful downstream AI work.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Owner-scoped endpoint-key resolution is needed for image proxy/editor routes.
|
- Owner-scoped endpoint-key resolution is needed for image proxy/editor routes.
|
||||||
- Media routes need a clear API-token policy: reject token callers, or implement owner/scope handling.
|
- Media routes need a clear API-token policy: reject token callers, or implement owner/scope handling.
|
||||||
- Direct handler ownership/cache tests cover generated-image denial and fallback branches; middleware-mounted serving, MIME/account-switch and live browser cache behavior still need broader checks.
|
- Generated-image serving needs live route tests for invalid filenames, rowless files, owned rows, null-owner rows, MIME/cache headers, and cross-owner behavior.
|
||||||
- Private revalidation replaces immutable generated-media caching; replace/save-over-original and account-switch behavior still warrant browser cache tests.
|
- Mutable generated filenames plus immutable cache headers need cache-busting tests for replace/save-over-original flows.
|
||||||
- Base64 JSON editor payload size limits need hardening; upload content sniffing should keep native/Docker parity coverage as dependencies change.
|
- Base64 JSON editor payload size limits need hardening; upload content sniffing should keep native/Docker parity coverage as dependencies change.
|
||||||
- Image MCP now receives trusted server-dispatch ownership; direct out-of-band MCP callers and cross-process recent-registry behavior remain separate trust/availability limits.
|
- MCP image generation needs an owner attribution decision or explicit admin-only documentation.
|
||||||
|
- `edit_image` tool route mapping appears stale.
|
||||||
- Emoji SVG proxy/cache and visual-report raw HTML/link sanitization need stronger tests.
|
- Emoji SVG proxy/cache and visual-report raw HTML/link sanitization need stronger tests.
|
||||||
- Optional image dependency fallbacks are mostly untested.
|
- Optional image dependency fallbacks are mostly untested.
|
||||||
|
|||||||
+12
-23
@@ -1,6 +1,6 @@
|
|||||||
# Integrations
|
# Integrations
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -80,11 +80,17 @@ The Cookbook scoped-agent surface currently exposes `cookbook:read` and `cookboo
|
|||||||
|
|
||||||
`src.integrations` owns generic API integration presets, `data/integrations.json`, API-key encryption/decryption, secret masking, plaintext-key migration, enabled integration prompt text, and `execute_api_call()`.
|
`src.integrations` owns generic API integration presets, `data/integrations.json`, API-key encryption/decryption, secret masking, plaintext-key migration, enabled integration prompt text, and `execute_api_call()`.
|
||||||
|
|
||||||
`routes.auth_routes` owns HTTP CRUD/test routes for these integrations, requiring an admin unless auth is explicitly disabled. Presets are public metadata. The ntfy test route is special: it publishes a real test notification to the configured reminder topic instead of only probing server health.
|
`routes.auth_routes` owns admin-only HTTP CRUD/test routes for these integrations. Presets are public metadata. The ntfy test route is special: it publishes a real test notification to the configured reminder topic instead of only probing server health.
|
||||||
|
|
||||||
`api_call` is the agent/tool execution path for configured integrations. It is blocked for non-admin/public users by tool security, accepts only relative paths, uses the admin-configured base URL/auth settings, and returns truncated external responses to the model, including a sentinel when long JSON lists are shortened. Admin-authored integration descriptions are prompt context; external responses remain untrusted data.
|
`api_call` is the agent/tool execution path for configured integrations. It is blocked for non-admin/public users by tool security, accepts only relative paths, uses the admin-configured base URL/auth settings, and returns truncated external responses to the model, including a sentinel when long JSON lists are shortened. Admin-authored integration descriptions are prompt context; external responses remain untrusted data.
|
||||||
|
|
||||||
`execute_api_call()` normalizes base URLs to HTTP(S) scheme, hostname, and path-only values, rejects request paths that are not relative absolute paths (`/...`) or that carry schemes/fragments, treats `/` as the base URL without appending an extra slash, and checks the final URL through `src.url_safety`. Link-local/metadata targets are always rejected; setting `INTEGRATION_API_BLOCK_PRIVATE_IPS=true` also rejects loopback/RFC1918/private addresses for operators who do not need LAN integrations.
|
`execute_api_call()` normalizes base URLs to HTTP(S) scheme, hostname, and
|
||||||
|
path-only values, rejects request paths that are not relative absolute paths
|
||||||
|
(`/...`) or that carry schemes/fragments, treats `/` as the base URL without
|
||||||
|
appending an extra slash, and checks the final URL through `src.url_safety`.
|
||||||
|
Link-local/metadata targets are always rejected; setting
|
||||||
|
`INTEGRATION_API_BLOCK_PRIVATE_IPS=true` also rejects loopback/RFC1918/private
|
||||||
|
addresses for operators who do not need LAN integrations.
|
||||||
|
|
||||||
After validation, `execute_api_call()` pins the outbound connection to the validated IP snapshot while preserving the configured URL, Host header, TLS server name, and redirect policy. DNS cannot select a different destination between SSRF validation and transport.
|
After validation, `execute_api_call()` pins the outbound connection to the validated IP snapshot while preserving the configured URL, Host header, TLS server name, and redirect policy. DNS cannot select a different destination between SSRF validation and transport.
|
||||||
|
|
||||||
@@ -153,7 +159,8 @@ This spec owns the cross-integration framing and agent/token/webhook surfaces. D
|
|||||||
- Missing or corrupt `data/integrations.json` loads as an empty list; non-object rows are ignored.
|
- Missing or corrupt `data/integrations.json` loads as an empty list; non-object rows are ignored.
|
||||||
- Plaintext generic integration API keys migrate to encrypted storage on load.
|
- Plaintext generic integration API keys migrate to encrypted storage on load.
|
||||||
- Webhook delivery has no retry/backoff queue; the persisted state is last status or sanitized last error.
|
- Webhook delivery has no retry/backoff queue; the persisted state is last status or sanitized last error.
|
||||||
- Webhook URLs are validated at create and delivery time, redirects are disabled, and delivery connects to the IP set validated immediately before the request.
|
- Webhook URLs are validated at create and delivery time, redirects are disabled,
|
||||||
|
and delivery connects to the IP set validated immediately before the request.
|
||||||
- Companion LAN detection is best-effort and falls back to local host/port defaults unless a valid `COMPANION_BASE_URL` is configured.
|
- Companion LAN detection is best-effort and falls back to local host/port defaults unless a valid `COMPANION_BASE_URL` is configured.
|
||||||
- `ODYSSEUS_URL` must be reachable from the external coding agent; no Docker/native URL rewrite is performed.
|
- `ODYSSEUS_URL` must be reachable from the external coding agent; no Docker/native URL rewrite is performed.
|
||||||
|
|
||||||
@@ -175,30 +182,12 @@ Current targeted coverage includes API-token CRUD basics, chat-scoped `/api/mode
|
|||||||
|
|
||||||
The integration audit also ran the targeted venv subset covering those areas with 52 passing tests and one warning.
|
The integration audit also ran the targeted venv subset covering those areas with 52 passing tests and one warning.
|
||||||
|
|
||||||
## Bound External Backends
|
|
||||||
|
|
||||||
Tool-backed integration calls consume `src.agent_runtime.remote_resources` backend identity. `src.integrations.execute_api_call()` refuses a bound call when the saved integration identity differs from the admitted resource; a changed configuration cannot silently inherit an earlier approval. MCP and client/host bridges have their own producer/configuration identities and do not gain permission from advertised schemas.
|
|
||||||
|
|
||||||
Scoped Cookbook adapters reject internal-tool transport/header calls instead of treating internal owner attribution as an external scoped credential. In auth-disabled mode they still require the native local-operator boundary before borrowing endpoint functions. Public API-token scopes remain distinct from browser admin and internal-loopback authority.
|
|
||||||
|
|
||||||
## Adapter Admission Limits
|
|
||||||
|
|
||||||
Generic `app_api` refuses decoded `/api/codex/cookbook` selectors before dispatch, so it cannot wrap the dedicated scoped adapter to borrow its permissions. Integration requests preserve their explicit query/body inputs and owner/backend binding; an observed `api_call` response is an external acknowledgement, not sealed proof of remote poststate. Public scoped credentials, browser admin access and native local operator admission remain separate boundaries.
|
|
||||||
|
|
||||||
## App Configuration Compatibility
|
|
||||||
|
|
||||||
Integration list/create/update/delete/connectivity-test handlers in `routes/auth_routes.py` use `_require_admin_user()`: explicit disabled-auth operator mode is admitted without a session, while auth-enabled access still requires an admin. Integration listing continues masking keys. This app-configuration exception does not relax user-store administration or scoped external API authorization.
|
|
||||||
|
|
||||||
## Delegated Upload Access
|
|
||||||
|
|
||||||
`routes/upload_routes.py` applies a separate delegated-token owner/scope gate to browser-compatible upload endpoints. `chat` or `documents:write` permits creation/OCR edits; either of those or `documents:read` permits download/thumbnail/OCR reads. Scope is an any-of intersection, not a requirement for every listed scope. Tokens remain confined to their actual owner's non-null-owned files even when the owner is an interactive admin; invalid/missing/sentinel token identity fails closed. This supplements global token middleware rather than changing other scoped API contracts.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Codex/Claude scoped routes, owner restoration, degraded 503 behavior, plugin zip contents, and helper-script path refusal need focused regression tests.
|
- Codex/Claude scoped routes, owner restoration, degraded 503 behavior, plugin zip contents, and helper-script path refusal need focused regression tests.
|
||||||
- Token profile/update behavior and Settings agent-token scope toggles need direct coverage.
|
- Token profile/update behavior and Settings agent-token scope toggles need direct coverage.
|
||||||
- Codex Cookbook scopes need continued Settings, route-check, and `ALLOWED_SCOPES` regression coverage.
|
- Codex Cookbook scopes need continued Settings, route-check, and `ALLOWED_SCOPES` regression coverage.
|
||||||
- Generic integration route fixtures cover CRUD/test admission with auth enabled and disabled, using substituted stores and network calls. Live notification delivery, `execute_api_call()` auth modes, response shaping, and frontend Settings/Admin integration still need broader coverage.
|
- Generic integration HTTP CRUD/test routes, `execute_api_call()` auth modes, response shaping, and frontend Settings/Admin flows need direct coverage.
|
||||||
- `do_manage_tokens()` does not match `/api/tokens` semantics for `ody_` prefix, owner, scopes, and cache invalidation.
|
- `do_manage_tokens()` does not match `/api/tokens` semantics for `ody_` prefix, owner, scopes, and cache invalidation.
|
||||||
- `do_manage_webhooks()` bypasses route behavior and does not cover signing-secret parity.
|
- `do_manage_webhooks()` bypasses route behavior and does not cover signing-secret parity.
|
||||||
- Companion read endpoints should either require `chat` scope or be documented as an explicit scope-policy exception.
|
- Companion read endpoints should either require `chat` scope or be documented as an explicit scope-policy exception.
|
||||||
|
|||||||
+33
-29
@@ -1,6 +1,6 @@
|
|||||||
# LLM Models And Endpoints
|
# LLM Models And Endpoints
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ This spec covers model/provider behavior in:
|
|||||||
- `src/endpoint_resolver.py`;
|
- `src/endpoint_resolver.py`;
|
||||||
- `src/foreground_model_routing.py`;
|
- `src/foreground_model_routing.py`;
|
||||||
- `src/model_discovery.py`;
|
- `src/model_discovery.py`;
|
||||||
- `src/model_context.py`, `src/generation_budget.py`, `src/model_pricing.py`, `src/model_profiles.py`, and `src/utility_effort.py`;
|
- `src/model_context.py`;
|
||||||
- `src/model_capabilities.py`;
|
- `src/model_capabilities.py`;
|
||||||
- `src/model_capability_readers/`;
|
- `src/model_capability_readers/`;
|
||||||
- `src/task_endpoint.py`;
|
- `src/task_endpoint.py`;
|
||||||
@@ -32,19 +32,41 @@ This spec covers model/provider behavior in:
|
|||||||
|
|
||||||
`llm_core` owns payload shape. Route files and chat/agent code should request a call; they should not duplicate provider-specific payload quirks.
|
`llm_core` owns payload shape. Route files and chat/agent code should request a call; they should not duplicate provider-specific payload quirks.
|
||||||
|
|
||||||
Kimi Code User-Agent discovery has both sync and async implementations. Async post and stream paths probe `/models` through their existing async client and await each candidate, so header negotiation does not block the event loop; both paths share the accepted-value cache and 403 fallback policy.
|
Kimi Code User-Agent discovery has both sync and async implementations. Async
|
||||||
|
post and stream paths probe `/models` through their existing async client and
|
||||||
|
await each candidate, so header negotiation does not block the event loop; both
|
||||||
|
paths share the accepted-value cache and 403 fallback policy.
|
||||||
|
|
||||||
Provider-specific behavior is part of this layer: `LLM_CONNECT_TIMEOUT` controls the connect budget for sync and streaming calls, Kimi Code endpoints retry a small whitelisted User-Agent set on 403 and cache the accepted value, official Moonshot/Kimi Code and Anthropic Opus 4.7+ payloads omit sampling controls where required, and major-only Opus IDs such as `claude-opus-5` also omit temperature instead of falling through numeric minor-version parsing. Reasoning models omit or clamp unsupported temperature values, while self-hosted compatible endpoints keep normal parameters unless detected otherwise. Mistral structured content is normalized in async utility calls as well as stream/chat paths, and Mistral/Moonshot/Kimi reasoning content, `gpt-oss` harmony output, DeepSeek V4 thinking identifiers, and native/OpenAI-compatible Ollama thinking formats keep hidden reasoning separate from visible text. Tool names that collide with GPT-OSS built-ins are aliased on the provider boundary and mapped back before execution. Copilot request metadata remains defensive against malformed `request_flags`.
|
Provider-specific behavior is part of this layer: `LLM_CONNECT_TIMEOUT` controls the connect budget for sync and streaming calls, Kimi Code endpoints retry a small whitelisted User-Agent set on 403 and cache the accepted value, official Moonshot/Kimi Code and Anthropic Opus 4.7+ payloads omit sampling controls where required, and major-only Opus IDs such as `claude-opus-5` also omit temperature instead of falling through numeric minor-version parsing. Reasoning models omit or clamp unsupported temperature values, while self-hosted compatible endpoints keep normal parameters unless detected otherwise. Mistral structured content is normalized in async utility calls as well as stream/chat paths, and Mistral/Moonshot/Kimi reasoning content, `gpt-oss` harmony output, DeepSeek V4 thinking identifiers, and native/OpenAI-compatible Ollama thinking formats keep hidden reasoning separate from visible text. Tool names that collide with GPT-OSS built-ins are aliased on the provider boundary and mapped back before execution. Copilot request metadata remains defensive against malformed `request_flags`.
|
||||||
|
|
||||||
## Canonical Provider And Model Shape
|
## Canonical Provider And Model Shape
|
||||||
|
|
||||||
`src.model_capabilities` owns canonical model family, task, modality, capability, limit, evidence, assertion, deterministic-control, probe-result, reasoning-control token, and display-query values. `src.model_capability_readers` owns endpoint-scoped stable identity, lightweight provider detection, record serialization, and normalization of already-fetched provider payloads. Readers do no network I/O. Model-specific observations are kept in `model-quirks.md`, not a runtime registry without a consumer.
|
`src.model_capabilities` owns canonical model family, task, modality,
|
||||||
|
capability, limit, evidence, assertion, deterministic-control, probe-result,
|
||||||
|
reasoning-control token, and display-query values.
|
||||||
|
`src.model_capability_readers` owns endpoint-scoped stable identity, lightweight
|
||||||
|
provider detection, record serialization, and normalization of already-fetched
|
||||||
|
provider payloads. Readers do no network I/O. Model-specific observations are
|
||||||
|
kept in `model-quirks.md`, not a runtime registry without a consumer.
|
||||||
|
|
||||||
Provider support and model support are different facts. A provider may expose tools, reasoning, vision, or multiple APIs while individual models differ. Provider-native readers describe where model evidence can appear. Current concrete readers cover generic OpenAI-compatible identity, OpenAI, OpenRouter, Google, Ollama, LM Studio, and llama.cpp. Identity-only model lists remain unknown.
|
Provider support and model support are different facts. A provider may expose
|
||||||
|
tools, reasoning, vision, or multiple APIs while individual models differ.
|
||||||
|
Provider-native readers describe where model evidence can appear. Current
|
||||||
|
concrete readers cover generic OpenAI-compatible identity, OpenAI, OpenRouter,
|
||||||
|
Google, Ollama, LM Studio, and llama.cpp. Identity-only model lists remain
|
||||||
|
unknown.
|
||||||
|
|
||||||
Reader dispatch uses an explicit vendor first, then endpoint kind, label-bounded hostname suffix, and common local-port hints. Generic payload handling accepts `data[]` or `models[]` items with `id`, `name`, or `model`; it does not accept a bare list and never promotes capability-looking fields. Unknown fields remain in the in-memory raw record. See [model-capability-canonical.md](model-capability-canonical.md), [model-quirks.md](model-quirks.md), and the [provider map](model-providers/_readme.md).
|
Reader dispatch uses an explicit vendor first, then endpoint kind, label-bounded hostname suffix, and common local-port hints. Generic payload handling accepts `data[]`
|
||||||
|
or `models[]` items with `id`, `name`, or `model`; it does not accept a bare
|
||||||
|
list and never promotes capability-looking fields. Unknown fields remain in
|
||||||
|
the in-memory raw record. See [model-capability-canonical.md](model-capability-canonical.md),
|
||||||
|
[model-quirks.md](model-quirks.md), and the
|
||||||
|
[provider map](model-providers/_readme.md).
|
||||||
|
|
||||||
Canonical records are exercised by focused unit tests but are not wired into runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers. The reader package’s `detect_vendor()` helper does have narrow runtime consumers in `src/ai_interaction.py` and gallery routes for OpenRouter image transport; that does not integrate canonical capability records. `routes/model_routes.py` model probes continue to return model IDs through their existing runtime path.
|
This canonical layer is currently exercised by focused unit tests but is not
|
||||||
|
wired into runtime discovery, endpoint resolution, model context, request
|
||||||
|
shaping, or frontend pickers. `routes/model_routes.py` model probes continue to
|
||||||
|
return model IDs through their existing runtime path.
|
||||||
|
|
||||||
Route-level probe helpers in `routes/model_routes.py` are the current exception: they build minimal provider-specific probe payloads using `llm_core` detection helpers. Keep probe behavior aligned with `llm_core` provider adapters. LLM provider HTTP clients and endpoint probes share `src.tls_overrides.llm_verify()`, which can add an operator-provided `LLM_CA_BUNDLE` on top of normal certificate verification without turning verification off or widening that trust to arbitrary URL fetches.
|
Route-level probe helpers in `routes/model_routes.py` are the current exception: they build minimal provider-specific probe payloads using `llm_core` detection helpers. Keep probe behavior aligned with `llm_core` provider adapters. LLM provider HTTP clients and endpoint probes share `src.tls_overrides.llm_verify()`, which can add an operator-provided `LLM_CA_BUNDLE` on top of normal certificate verification without turning verification off or widening that trust to arbitrary URL fetches.
|
||||||
|
|
||||||
@@ -64,7 +86,7 @@ OpenAI-compatible model-list URL construction preserves `/v1` bases and inserts
|
|||||||
|
|
||||||
`routes/session_routes.py` owns binding sessions to endpoint IDs, owner-scoped header construction, raw-endpoint rejection for non-admin users, model validation, and persisted session headers. Compare panes and normal chat session creation use this path.
|
`routes/session_routes.py` owns binding sessions to endpoint IDs, owner-scoped header construction, raw-endpoint rejection for non-admin users, model validation, and persisted session headers. Compare panes and normal chat session creation use this path.
|
||||||
|
|
||||||
`ModelEndpoint` rows own API keys, base URLs, cached/hidden/pinned models, model type, endpoint kind, refresh mode/interval/timeout, supports-tools state, per-model tool-mode overrides, nullable owner, optional provider-auth linkage, and provider metadata. `owner = NULL` means legacy/shared; non-null rows are private to that owner, while ordinary admin inventory can see all. ChatGPT Subscription rows are an exception: account visibility requires exact effective-owner equality even for admins. Secret fields must remain encrypted and scrubbed in responses.
|
`ModelEndpoint` rows own API keys, base URLs, cached/hidden/pinned models, model type, endpoint kind, refresh mode/interval/timeout, supports-tools state, nullable owner, optional provider-auth linkage, and provider metadata. `owner = NULL` means legacy/shared; non-null rows are private to that owner, while admins can see all. Secret fields must remain encrypted and scrubbed in responses.
|
||||||
|
|
||||||
Decrypted endpoint headers can be copied into session metadata for chat use. Endpoint deletion must clear dependent settings and copied session headers.
|
Decrypted endpoint headers can be copied into session metadata for chat use. Endpoint deletion must clear dependent settings and copied session headers.
|
||||||
|
|
||||||
@@ -72,7 +94,7 @@ Decrypted endpoint headers can be copied into session metadata for chat use. End
|
|||||||
|
|
||||||
`src.model_discovery` owns host/env/Tailscale/local-port scanning for model servers. Admin `/api/providers` and `/api/discover` use that scanner; endpoint CRUD, test, refresh, and hidden-model controls are frontend-owned by `static/js/admin.js`.
|
`src.model_discovery` owns host/env/Tailscale/local-port scanning for model servers. Admin `/api/providers` and `/api/discover` use that scanner; endpoint CRUD, test, refresh, and hidden-model controls are frontend-owned by `static/js/admin.js`.
|
||||||
|
|
||||||
`/api/models` is the normal picker/catalog surface. It is auth/owner scoped, per-user/admin-flag cached briefly, can trigger background refresh, preserves offline endpoint rows, filters hidden models, and preserves pinned model IDs for UI selection. API-token callers must carry `chat` scope and a token owner before they can list models. API/proxy endpoint inventory is visible by default until an explicit `pinned_models` allow-list is saved; an explicit empty list means show none, and legacy hidden-list state is upgraded to the equivalent pins so endpoint settings, picker checkboxes, and chat agree. Proxy/API endpoints can be marked cached-first/manual so large upstream catalogs are not repeatedly probed, while explicit refresh paths use longer manual timeouts. Local endpoints get cheap reachability probes before expensive refreshes where possible, and endpoint responses can include explicit `supports_tools` state for schema-emission heuristics. Google Gemini API endpoints use the native paginated `generativelanguage.googleapis.com/v1beta/models` catalog, send API keys in `x-goog-api-key`, retain only content-generation model IDs, and default to manual refresh unless the caller explicitly chooses another mode. Probe failure returns no curated Google fallback. `static/js/models.js` and `static/js/modelPicker.js` own the sidebar/picker catalog; `static/js/model/matchKey.js` owns longest-substring model-info/pricing key matching; `static/js/settings.js` coordinates default, utility, vision and fallback selectors; `static/js/settings/imageModels.js` and `static/js/settings/speech.js` own their extracted image and TTS/STT panels.
|
`/api/models` is the normal picker/catalog surface. It is auth/owner scoped, per-user/admin-flag cached briefly, can trigger background refresh, preserves offline endpoint rows, filters hidden models, and preserves pinned model IDs for UI selection. API-token callers must carry `chat` scope and a token owner before they can list models. API/proxy endpoint inventory is visible by default until an explicit `pinned_models` allow-list is saved; an explicit empty list means show none, and legacy hidden-list state is upgraded to the equivalent pins so endpoint settings, picker checkboxes, and chat agree. Proxy/API endpoints can be marked cached-first/manual so large upstream catalogs are not repeatedly probed, while explicit refresh paths use longer manual timeouts. Local endpoints get cheap reachability probes before expensive refreshes where possible, and endpoint responses can include explicit `supports_tools` state for schema-emission heuristics. Google Gemini API endpoints use the native paginated `generativelanguage.googleapis.com/v1beta/models` catalog, send API keys in `x-goog-api-key`, retain only content-generation model IDs, and default to manual refresh unless the caller explicitly chooses another mode. Probe failure returns no curated Google fallback. `static/js/models.js` and `static/js/modelPicker.js` own the sidebar/picker catalog; `static/js/model/matchKey.js` owns longest-substring model-info/pricing key matching; `static/js/settings.js` owns default, utility, vision, image, TTS, STT, and fallback selectors.
|
||||||
|
|
||||||
`src.task_endpoint` owns background-task endpoint/model resolution for task routes and scheduler callers. It resolves `task_endpoint_id`/`task_model` through the normal endpoint resolver with owner context.
|
`src.task_endpoint` owns background-task endpoint/model resolution for task routes and scheduler callers. It resolves `task_endpoint_id`/`task_model` through the normal endpoint resolver with owner context.
|
||||||
|
|
||||||
@@ -80,7 +102,7 @@ Cookbook and HWFit own local model download, serve, ranking, and auto-registrati
|
|||||||
|
|
||||||
## Context Length
|
## Context Length
|
||||||
|
|
||||||
`src.model_context` owns model context-length lookup/query and token estimation. Cache keys include endpoint plus model so identical model names on different endpoints do not bleed context-window data. Local llama.cpp context probes try `/slots` and then `/props.default_generation_settings.n_ctx`. Unknown proxy/API models can pick up real context windows from endpoint catalog metadata such as `context_length`; otherwise unknown lengths stay explicit unknowns rather than default values. Known lengths feed chat/agent token-budget scaling through `src.context_budget`. Token estimation counts assistant `tool_calls` arguments so compaction sees tool-only turns instead of underestimating them. Chat/agent context budgeting should call this layer instead of hardcoding model windows.
|
`src.model_context` owns model context-length lookup/query and token estimation. Cache keys include endpoint plus model so identical model names on different endpoints do not bleed context-window data. Unknown proxy/API models can pick up real context windows from endpoint catalog metadata such as `context_length`; otherwise unknown lengths stay explicit unknowns rather than default values. Known lengths feed chat/agent token-budget scaling through `src.context_budget`. Token estimation counts assistant `tool_calls` arguments so compaction sees tool-only turns instead of underestimating them. Chat/agent context budgeting should call this layer instead of hardcoding model windows.
|
||||||
|
|
||||||
## Runtime Fallback And Routing
|
## Runtime Fallback And Routing
|
||||||
|
|
||||||
@@ -105,18 +127,6 @@ Provider tool calls are untrusted requests, not authorization. `supports_tools`
|
|||||||
- SSE/stream parsers tolerate null choice/usage/tool-call entries and null streaming tool-call arguments; provider events should degrade to empty text or shaped stream errors instead of crashing the chat loop.
|
- SSE/stream parsers tolerate null choice/usage/tool-call entries and null streaming tool-call arguments; provider events should degrade to empty text or shaped stream errors instead of crashing the chat loop.
|
||||||
- Provider adapters carry small model-specific quirks: Opus 4.7+ and official Kimi/Moonshot code payloads omit `temperature`, Kimi/Moonshot/Mistral reasoning content is preserved separately, ChatGPT Subscription refreshes bearer credentials, native Ollama can handle multimodal content, and Ollama `/v1` responses for Qwen3/Gemma4-style thinking can suppress thinking text when requested.
|
- Provider adapters carry small model-specific quirks: Opus 4.7+ and official Kimi/Moonshot code payloads omit `temperature`, Kimi/Moonshot/Mistral reasoning content is preserved separately, ChatGPT Subscription refreshes bearer credentials, native Ollama can handle multimodal content, and Ollama `/v1` responses for Qwen3/Gemma4-style thinking can suppress thinking text when requested.
|
||||||
|
|
||||||
## Additional Runtime Owners
|
|
||||||
|
|
||||||
`src.generation_budget` estimates native tool-schema and image reserve costs, fits output limits to the remaining context budget, and parses provider context errors for bounded recovery. `llm_core` uses these helpers in async/streaming paths; recovery is separate from switching to a fallback model. `src.model_context.estimate_text_tokens()` counts non-ASCII characters individually and Latin characters at roughly 0.3 tokens each, including tool-call names/arguments. These remain estimates, not tokenizer guarantees.
|
|
||||||
|
|
||||||
`src.model_profiles` selects the Odysseus compact schema and progressive-thinking profile from bounded `odysseus`/`ajax` tokens in the model ID leaf. `supports_user_thinking_toggle()` uses explicit model-family patterns and suppresses the toggle for that profile and GPT/o-series leaves; these are runtime heuristics outside the canonical reader. Endpoint `model_tool_modes` accepts `none`, `compact`, or `full` per model, with legacy aliases normalized by model routes. This is schema/routing configuration, not tool execution authority.
|
|
||||||
|
|
||||||
`src.utility_effort` resolves owner-aware utility effort, including fallback-specific overrides and eligible session-route effort. Validation currently uses ChatGPT model metadata, including maintained defaults and model-name fallback metadata; it is not evidence from the canonical reader. `src.model_pricing` owns server-side known-price estimates; unknown models return no estimate and local/subscription endpoints are excluded from per-token cost tracking.
|
|
||||||
|
|
||||||
Featherless endpoints use an explicit pin inventory instead of bulk catalog refresh. Admin `GET /api/model-endpoints/{ep_id}/catalog-search` searches active conversational models available on the current plan with bounded pagination and a 45-second in-memory query cache. New Featherless rows default to an empty pin list. `/api/models?refresh=true&wait_refresh=true` can wait up to 15 seconds on the shared background refresh.
|
|
||||||
|
|
||||||
`src.endpoint_resolver.resolve_owner_registered_endpoint()` accepts only an enabled owner-visible registered base or its derived chat URL, returning the server-owned row. URL identity includes scheme, host, effective port, and path; caller credentials, query strings and fragments are rejected. `same_endpoint_base()` governs exact origin/base-path credential reuse. Native runtimes can rewrite persisted `host.docker.internal` bases to loopback.
|
|
||||||
|
|
||||||
## Security Policy
|
## Security Policy
|
||||||
|
|
||||||
- Endpoint API keys are encrypted in `ModelEndpoint.api_key` and never returned by endpoint APIs; admin surfaces return key presence only.
|
- Endpoint API keys are encrypted in `ModelEndpoint.api_key` and never returned by endpoint APIs; admin surfaces return key presence only.
|
||||||
@@ -133,15 +143,9 @@ Featherless endpoints use an explicit pin inventory instead of bulk catalog refr
|
|||||||
- utility model fallbacks for summarization/extraction;
|
- utility model fallbacks for summarization/extraction;
|
||||||
- frontend Settings and model picker endpoint management.
|
- frontend Settings and model picker endpoint management.
|
||||||
|
|
||||||
## Discovery And Route Identity Details
|
|
||||||
|
|
||||||
`src.model_discovery` timestamps successful Tailscale host queries, including an empty result, for the 60-second cache. A failed query does not become a successful empty observation. This discovery cache is separate from endpoint URL resolution and model-context probe caches.
|
|
||||||
|
|
||||||
Exact registered endpoint authority rejects URL credentials, parameters, and any query or fragment delimiter, including an empty one; it accepts the registered base or an explicitly derived inference route rather than broad same-host aliases. Owner-visible shared endpoint rows remain distinct from exact-owner-only data. Utility effort forwarding remains explicit at participating callers; a configured off/none/default selection can suppress session effort rather than being treated as a missing override.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Runtime provider detection, model curation, and frontend logos are still split across `llm_core`, `model_routes`, and `providers.js`; canonical records have no runtime model-catalog consumer yet, while provider detection is reused by media paths.
|
- Runtime provider detection, model curation, and frontend logos are still split across `llm_core`, `model_routes`, and `providers.js`; the canonical reader package has no production consumer yet.
|
||||||
- Provider-specific behavior is concentrated in `llm_core.py`, which is large and easy to regress.
|
- Provider-specific behavior is concentrated in `llm_core.py`, which is large and easy to regress.
|
||||||
- Several runtime request builders still use model-name heuristics. They should migrate only after endpoint/provider code supplies structured identity and a real consumer contract; the canonical catalog does not add a parallel quirk matcher.
|
- Several runtime request builders still use model-name heuristics. They should migrate only after endpoint/provider code supplies structured identity and a real consumer contract; the canonical catalog does not add a parallel quirk matcher.
|
||||||
- Endpoint identity and fallback behavior need careful review when new OAuth/subscription providers are added.
|
- Endpoint identity and fallback behavior need careful review when new OAuth/subscription providers are added.
|
||||||
|
|||||||
+10
-39
@@ -1,6 +1,6 @@
|
|||||||
# Memory And Skills
|
# Memory And Skills
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@2e2bb52 | 2026-08-16
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ This spec covers persistent memory and user skills in:
|
|||||||
- provider abstractions in `src/memory_provider.py`;
|
- provider abstractions in `src/memory_provider.py`;
|
||||||
- LLM extraction/audit in `services/memory/memory_extractor.py`;
|
- LLM extraction/audit in `services/memory/memory_extractor.py`;
|
||||||
- skill storage, format, import, and extraction in `services/memory/skills.py`, `services/memory/skill_format.py`, `services/memory/skill_importer.py`, and `services/memory/skill_extractor.py`;
|
- skill storage, format, import, and extraction in `services/memory/skills.py`, `services/memory/skill_format.py`, `services/memory/skill_importer.py`, and `services/memory/skill_extractor.py`;
|
||||||
- skill HTTP/compatibility bindings in `routes/skills_routes.py` and evaluation/audit/job/model/prompt/publication services under `services/skills/`;
|
- skill routes in `routes/skills_routes.py`;
|
||||||
- prompt/tool call sites in `src/chat_processor.py`, `src/agent_loop.py`, `src/ai_interaction.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_security.py`;
|
- prompt/tool call sites in `src/chat_processor.py`, `src/agent_loop.py`, `src/ai_interaction.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_security.py`;
|
||||||
- MCP and Codex surfaces in `mcp_servers/memory_server.py` and `routes/codex_routes.py`;
|
- MCP and Codex surfaces in `mcp_servers/memory_server.py` and `routes/codex_routes.py`;
|
||||||
- backup/admin/CLI surfaces in `routes/backup_routes.py`, canonical `routes/admin_wipe/admin_wipe_routes.py` plus its shim, `scripts/odysseus-memory`, `scripts/odysseus-skills`, and `scripts/odysseus-backup`;
|
- backup/admin/CLI surfaces in `routes/backup_routes.py`, canonical `routes/admin_wipe/admin_wipe_routes.py` plus its shim, `scripts/odysseus-memory`, `scripts/odysseus-skills`, and `scripts/odysseus-backup`;
|
||||||
@@ -43,23 +43,25 @@ Extraction handles reasoning-model response shapes and records explicit dislike/
|
|||||||
|
|
||||||
`services/memory/skills.py` owns disk-backed skill storage under `data/skills/<category>/<name>/SKILL.md`, plus `_usage.json` usage/audit sidecars. Legacy `data/skills.json` is a read-only fallback/import source, not the current write shape.
|
`services/memory/skills.py` owns disk-backed skill storage under `data/skills/<category>/<name>/SKILL.md`, plus `_usage.json` usage/audit sidecars. Legacy `data/skills.json` is a read-only fallback/import source, not the current write shape.
|
||||||
|
|
||||||
`services/memory/skill_format.py` owns frontmatter/body parsing and emission. Quoted scalar parsing/emission is symmetric: JSON escapes decode once, UTF-8/non-ASCII stays intact, emitted values escape line separators safely, and invalid JSON-style escapes fall back to literal text instead of compounding backslashes on every save. `services/memory/skill_importer.py` resolves public GitHub/skills URLs, fetches bundle files with strict public-network URL safety, and chooses/imports `SKILL.md`. Import disables automatic redirects, follows at most five hops, validates and resolves each hop, then connects only to the validated IP snapshot through a pinned transport while preserving URL, Host, and TLS identity; GitHub final-host checks and file/size limits still apply. `routes/skills_routes.py` owns HTTP CRUD/search/index/import, authentication/owner verification, exact approval validation, request models and admin-gated built-in instruction overrides. Evaluation, execution, job coordination, audit sequencing and publication policy now live under `services/skills/`; durable identity/storage/lifecycle remains in `services/memory/skills.py`.
|
`services/memory/skill_format.py` owns frontmatter/body parsing and emission. Quoted scalar parsing/emission is symmetric: JSON escapes decode once, UTF-8/non-ASCII stays intact, emitted values escape line separators safely, and invalid JSON-style escapes fall back to literal text instead of compounding backslashes on every save. `services/memory/skill_importer.py` resolves public GitHub/skills URLs, fetches bundle files with strict public-network URL safety, and chooses/imports `SKILL.md`. Import disables automatic redirects, follows at most five hops, validates and resolves each hop, then connects only to the validated IP snapshot through a pinned transport while preserving URL, Host, and TLS identity; GitHub final-host checks and file/size limits still apply. `routes/skills_routes.py` owns CRUD/search/index/import, owner filtering, skill test/audit jobs, and admin-gated built-in tool instruction overrides.
|
||||||
|
|
||||||
Skill extraction is owned by `services/memory/skill_extractor.py`. It can suggest or save skills from conversations, tries valid brace-delimited JSON candidates with `JSONDecoder.raw_decode()`, rejects ambiguous multiple top-level JSON objects instead of guessing, and saved skills remain user-editable data.
|
Skill extraction is owned by `services/memory/skill_extractor.py`. It can suggest or save skills from conversations, tries valid brace-delimited JSON candidates with `JSONDecoder.raw_decode()`, rejects ambiguous multiple top-level JSON objects instead of guessing, and saved skills remain user-editable data.
|
||||||
|
|
||||||
Agent skill behavior:
|
Agent skill behavior:
|
||||||
|
|
||||||
- matched skills are owner-scoped, confidence-gated, usage-counted, and wrapped as untrusted context;
|
- matched skills are owner-scoped, confidence-gated, usage-counted, and wrapped as untrusted context;
|
||||||
- `index_for()` exposes built-ins and eligible user skills gated by current approval policy, platform, local-model status, and toolsets; `active_toolsets=None` means the caller has no explicit toolset knowledge and does not hide `requires_toolsets` skills, while an explicit list applies the gate;
|
- `index_for()` exposes published skills plus teacher-escalation drafts gated by platform and toolsets; `active_toolsets=None` means the caller has no explicit toolset knowledge and does not hide `requires_toolsets` skills, while an explicit list applies the gate;
|
||||||
- user prefs such as skills enabled, auto-approve, and max injected skills shape runtime insertion;
|
- user prefs such as skills enabled, auto-approve, and max injected skills shape runtime insertion;
|
||||||
- the level-0 skill index passes the acting owner into `index_for()`, applies active-toolset/platform and local-model gates, and returns its user-editable index block separately for untrusted-context wrapping.
|
- the level-0 base skill index currently calls `index_for(owner=None)`, so it is not fully owner-scoped.
|
||||||
- skill tests use the configured utility model rather than the chat default and wrap user-editable skill text as untrusted context; approval continuation for a test or teacher-generated skill uses the same exact-action gate as the normal agent loop.
|
- skill tests use the configured utility model rather than the chat default and wrap user-editable skill text as untrusted context; approval continuation for a test or teacher-generated skill uses the same exact-action gate as the normal agent loop.
|
||||||
|
|
||||||
## Tools, MCP, And Backup
|
## Tools, MCP, And Backup
|
||||||
|
|
||||||
Native `manage_memory` and `manage_skills` tool paths pass owner context and use in-process policy gates. `manage_skills` requires an explicit action instead of silently defaulting a malformed call. Manual memory add can choose a category, and route-side manual add validates the source session owner before attaching session-derived memories. `mcp_servers/memory_server.py` lazy-initializes `src` managers and exposes list/add/edit/delete/search. It can scope to `ODYSSEUS_MCP_MEMORY_OWNER` or `ODYSSEUS_MEMORY_OWNER`; if the JSON store contains owner-bearing entries and no owner env is configured, it returns an owner-scope error instead of listing or mutating across owners. Ownerless stores remain ownerless compatibility mode.
|
Native `manage_memory` and `manage_skills` tool paths pass owner context and use in-process policy gates. `manage_skills` requires an explicit action instead of silently defaulting a malformed call. Manual memory add can choose a category, and route-side manual add validates the source session owner before attaching session-derived memories. `mcp_servers/memory_server.py` lazy-initializes `src` managers and exposes list/add/edit/delete/search. It can scope to `ODYSSEUS_MCP_MEMORY_OWNER` or `ODYSSEUS_MEMORY_OWNER`; if the JSON store contains owner-bearing entries and no owner env is configured, it returns an owner-scope error instead of listing or mutating across owners. Ownerless stores remain ownerless compatibility mode.
|
||||||
|
|
||||||
The direct `odysseus-memory add` CLI tolerates non-object legacy/corrupt rows when checking whether its newly added entry is already present; it ignores those rows instead of calling mapping methods on them and crashing the add.
|
The direct `odysseus-memory add` CLI tolerates non-object legacy/corrupt rows
|
||||||
|
when checking whether its newly added entry is already present; it ignores
|
||||||
|
those rows instead of calling mapping methods on them and crashing the add.
|
||||||
|
|
||||||
`/api/export` owner-filters memories and skills. `/api/import` imports skills through current disk-backed `SkillsManager` APIs, stamping missing owners to the importer and preserving supported skill metadata. Full data snapshots through `scripts/odysseus-backup` preserve on-disk skill trees, memory JSON, and caches differently from JSON import/export.
|
`/api/export` owner-filters memories and skills. `/api/import` imports skills through current disk-backed `SkillsManager` APIs, stamping missing owners to the importer and preserving supported skill metadata. Full data snapshots through `scripts/odysseus-backup` preserve on-disk skill trees, memory JSON, and caches differently from JSON import/export.
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ Owner isolation is surface-specific:
|
|||||||
- native memory/skill tools are expected to pass owner context;
|
- native memory/skill tools are expected to pass owner context;
|
||||||
- Codex exposes scoped token memory behavior separately;
|
- Codex exposes scoped token memory behavior separately;
|
||||||
- normal memory/skills routes are cookie/current-user surfaces, not scoped token APIs;
|
- normal memory/skills routes are cookie/current-user surfaces, not scoped token APIs;
|
||||||
- MCP memory uses an environment-configured owner for owner-scoped stores, while the agent level-0 skill index now passes the acting owner;
|
- MCP memory uses an environment-configured owner for owner-scoped stores, while the agent level-0 skill index currently has ownerless/global behavior;
|
||||||
- vector dedup during memory extraction suppresses only same-owner or legacy-ownerless vector matches.
|
- vector dedup during memory extraction suppresses only same-owner or legacy-ownerless vector matches.
|
||||||
|
|
||||||
Skill test/audit flows intentionally run user-editable `SKILL.md` content as instructions inside controlled jobs. Those jobs rely on route owner checks, admin gates where applicable, and tool execution policy.
|
Skill test/audit flows intentionally run user-editable `SKILL.md` content as instructions inside controlled jobs. Those jobs rely on route owner checks, admin gates where applicable, and tool execution policy.
|
||||||
@@ -105,41 +107,10 @@ Existing tests cover memory extraction/degraded vectors, owner isolation, unread
|
|||||||
|
|
||||||
Route-level memory CRUD/security, skills route security, MCP memory behavior, vector degraded writes, compatibility facade owner behavior, backup skill import, admin vector cleanup, and frontend endpoint wiring need broader coverage.
|
Route-level memory CRUD/security, skills route security, MCP memory behavior, vector degraded writes, compatibility facade owner behavior, backup skill import, admin vector cleanup, and frontend endpoint wiring need broader coverage.
|
||||||
|
|
||||||
## Skill Approval, Retrieval, And Persona Continuity
|
|
||||||
|
|
||||||
`services/memory/builtin_skills.py` synchronizes tracked `BUILTIN_SKILLS_DIR` procedures at startup into an immutable ownerless shared catalog, published with confidence 1.0. Owner-aware list/view paths expose these built-ins alongside the owner's skills; built-ins skip user audits. User-editable memories and imported/extracted skills retain their untrusted-context treatment.
|
|
||||||
|
|
||||||
`SkillsManager.index_for()` excludes failing user audits and unnecessary skills. With automatic approval enabled, a user skill needs published status, a passing audit, and confidence at the owner's current threshold; a stale published flag alone is insufficient. Manual publication supplies approval when automatic approval is disabled. `remote-only` skills are hidden from local/private-endpoint model indices. Search also gates toolsets and platforms, combining lexical scores with optional semantic scores from `src/skill_index.py`; semantic retrieval reuses an already-ready ToolIndex embedding lane and falls back to lexical search without a foreground cold model load.
|
|
||||||
|
|
||||||
Skill audit protocol v2 separates functional pass/fail from no-skill baseline utility, storing summary, baseline verdict, usefulness, saved turns/tool calls, and audit version in `_usage.json`. Legacy inconclusive v1 results are eligible for re-audit. `services/memory/skill_lifecycle.py` supplies a bounded automatic queue: transient/inconclusive checks retry daily, failed/needs-work repairs weekly, and built-in/binned/pass/skipped entries are excluded. Route jobs preserve request authority and exact-action approval continuation.
|
|
||||||
|
|
||||||
Memory extraction automatically pins new identity/contact facts only while the count of already-pinned identity/contact entries for the acting owner and compatible ownerless entries is below five; it does not unpin older entries. Extra facts remain retrievable by relevance. Persona continuity extraction stores concise notes in preset/persona data through `PresetManager.update_persona_memory()`, separately from global memory JSON; health personas use a cautious medical brief schema and persona deletion removes its notes.
|
|
||||||
|
|
||||||
## Mutation And Audit Lifecycle Limits
|
|
||||||
|
|
||||||
Memory edit/delete paths reject empty identities instead of allowing an empty substring match, and mutating operations preserve unreadable-store refusal. Persona extraction updates an existing persona rather than creating a missing persona implicitly; persona notes remain separate from global memory.
|
|
||||||
|
|
||||||
`services/skills/publication.py` implements batch finalization behind the route compatibility wrapper `_finalize_audit_batch()` and changes only skills processed by that job, protects approval-required results, and retains binned duplicates on disk while excluding them from retrieval. Name normalization and a 0.38 token-Jaccard threshold group potential duplicates; this heuristic is not semantic equivalence. Functional success and baseline usefulness remain separate audit dimensions, so a successful baseline does not itself make the skill functionally inconclusive.
|
|
||||||
|
|
||||||
`SkillAuditUnavailable` stops the batch without treating infrastructure failure as a skill verdict. Other audit errors can record inconclusive results. Manual test and audit agent streams use `contextlib.aclosing`, including early exits for exact-action approval, so the originating agent task closes rather than remaining suspended behind a pending choice. These lifecycle guarantees do not establish successful model execution when the provider or containment backend is unavailable.
|
|
||||||
|
|
||||||
## Skill Test Endpoint Recovery
|
|
||||||
|
|
||||||
Skill tests prefer the configured utility route; their registered fallback resolves server-owned runtime credentials and does not trust caller-supplied provider headers. The audit-arm path raises `SkillAuditUnavailable` on stream failure and stops the batch without a skill verdict. Manual primary-stream exceptions instead retain a partial transcript for subsequent baseline/evaluation; if grading returns `fail`, confidence can still decrease. An unavailable result is not guaranteed for every manual-test failure.
|
|
||||||
|
|
||||||
## Skills Service Boundaries
|
|
||||||
|
|
||||||
`services/skills/evaluation.py` owns verdict parsing/grading, necessity/retrieval assessment, efficiency fields and markdown improvement. `agent_runner.py` owns manual/audit agent streams, transcripts, request/exact-approval authority and same-task `aclosing`. `audit_workflow.py` owns per-skill checks, self-edit/worker retry, optional distinct-teacher rewrite, sequential batches and scheduled selection. `job_coordinator.py` owns transient job transitions, candidate selection, safe polling projections and cancellation requests. `model_selection.py` resolves owner-scoped worker/teacher credentials and utility fallback; `prompts.py` constructs tasks and explicitly untrusted skill messages. `publication.py` applies confidence/duplicate/publishing policy through SkillsManager; its preferences adapter still imports `routes.prefs_routes`.
|
|
||||||
|
|
||||||
Routes retain patchable aliases/wrappers, including the service-owned manual/audit registry dictionaries and `SkillAuditUnavailable`. A frozen `AuditDependencies` is built from current route bindings at service entry, preserving caller/test seams without rebinding service globals or importing the Skills route module from services. `app.py` still registers the same router and built-in actions still use route compatibility helpers. Registries are process-local jobs, not durable stores.
|
|
||||||
|
|
||||||
Manual completion adjusts confidence (pass .95, needs_work .6, fail .4) without publishing. Audits compare skill/no-skill arms on one task; clean/self-edit/teacher passes use .95/.85/.8, and continued non-pass after retries becomes draft at .35. Initial unknown/inconclusive verdicts preserve publication state during per-skill finalization; this does not cover rewrite retries or later batch duplicate finalization. Per-skill auto-publish requires enabled owner policy/threshold and no necessity/utility/duplicate blocker. Batch duplicate finalization instead selects a processed passing keeper at or above the threshold, can publish it under the owner policy without reapplying those per-skill blockers, and bins other eligible processed duplicates; approval-required and unrelated skills remain protected from that batch mutation.
|
|
||||||
|
|
||||||
Approval validation stays in HTTP: pending job/id/owner/empty-session must match and the sealed grant is consumed with `allow_continuation=False` before continuation. Resumed skill/transcript context remains untrusted; unattended arms deny encountered approvals. Infrastructure unavailability stops a batch without a skill verdict or finalization. Existing limits remain: rewrite retry results other than pass, including unknown/inconclusive or an approval pause, can still lead to demotion, and manual cancellation may retain private continuation state. Extraction does not add model/containment availability or broad behavioral equivalence guarantees.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- `services/memory/service.py` needs an explicit owner-scope/support decision before it is treated as a public memory API.
|
- `services/memory/service.py` needs an explicit owner-scope/support decision before it is treated as a public memory API.
|
||||||
|
- The agent level-0 skill index should thread owner or be documented as an intentional local/global index.
|
||||||
- MCP memory still needs a deliberate multi-user UX/config decision, but current behavior avoids cross-owner access when owner-bearing rows exist without an explicit MCP owner env.
|
- MCP memory still needs a deliberate multi-user UX/config decision, but current behavior avoids cross-owner access when owner-bearing rows exist without an explicit MCP owner env.
|
||||||
- Memory JSON import does not rebuild vector indexes.
|
- Memory JSON import does not rebuild vector indexes.
|
||||||
- Admin wipe vector clearing is currently ineffective.
|
- Admin wipe vector clearing is currently ineffective.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Canonical Provider And Model Capability Layer
|
# Canonical Provider And Model Capability Layer
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-14
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -15,9 +15,14 @@ This spec covers the implementation introduced on current `dev` in:
|
|||||||
- regression coverage in `tests/test_model_capabilities.py` and
|
- regression coverage in `tests/test_model_capabilities.py` and
|
||||||
`tests/test_model_capability_readers.py`.
|
`tests/test_model_capability_readers.py`.
|
||||||
|
|
||||||
The layer normalizes already-fetched JSON-compatible values. It performs no network I/O, does not shape provider requests, does not persist its output, and does not authorize model or tool use. No production caller currently consumes the canonical records outside this package; runtime integration remains later work.
|
The layer normalizes already-fetched JSON-compatible values. It performs no
|
||||||
|
network I/O, does not shape provider requests, does not persist its output, and
|
||||||
|
does not authorize model or tool use. No production caller currently consumes
|
||||||
|
the canonical records outside this package; runtime integration remains later
|
||||||
|
work.
|
||||||
|
|
||||||
There is no `src/provider_capability_schemas.py`, capability-specific diagnostics module, or runtime model-quirk registry on current `dev`.
|
There is no `src/provider_capability_schemas.py`, capability-specific
|
||||||
|
diagnostics module, or runtime model-quirk registry on current `dev`.
|
||||||
|
|
||||||
## Layer Boundaries
|
## Layer Boundaries
|
||||||
|
|
||||||
@@ -42,7 +47,9 @@ There is no `src/provider_capability_schemas.py`, capability-specific diagnostic
|
|||||||
capability object, assertions, deterministic controls, and optional raw
|
capability object, assertions, deterministic controls, and optional raw
|
||||||
provider evidence.
|
provider evidence.
|
||||||
|
|
||||||
Provider transport support and per-model support are separate facts. Request and response adapters remain in `src.llm_core` and related provider modules. Model-specific observations remain in [model-quirks.md](model-quirks.md).
|
Provider transport support and per-model support are separate facts. Request
|
||||||
|
and response adapters remain in `src.llm_core` and related provider modules.
|
||||||
|
Model-specific observations remain in [model-quirks.md](model-quirks.md).
|
||||||
|
|
||||||
## Current Serialized Shapes
|
## Current Serialized Shapes
|
||||||
|
|
||||||
@@ -63,13 +70,20 @@ Provider transport support and per-model support are separate facts. Request and
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`ModelCapabilityRecord.to_dict()` wraps that value with `vendor`, `model_id`, `stable_model_id`, `display_name`, `capability_assertions`, and `deterministic_controls`. It does not currently emit a schema version or the flat `provider`/`model`/`features`/`controls` shape. Raw provider fields are included only when the caller passes `include_raw=True`.
|
`ModelCapabilityRecord.to_dict()` wraps that value with `vendor`, `model_id`,
|
||||||
|
`stable_model_id`, `display_name`, `capability_assertions`, and
|
||||||
|
`deterministic_controls`. It does not currently emit a schema version or the
|
||||||
|
flat `provider`/`model`/`features`/`controls` shape. Raw provider fields are
|
||||||
|
included only when the caller passes `include_raw=True`.
|
||||||
|
|
||||||
Endpoint configuration can explicitly map `model_type=llm` to chat and `model_type=image` to image generation. Missing or unrecognized endpoint types stay unknown rather than silently becoming chat-capable in this schema layer.
|
Endpoint configuration can explicitly map `model_type=llm` to chat and
|
||||||
|
`model_type=image` to image generation. Missing or unrecognized endpoint types
|
||||||
|
stay unknown rather than silently becoming chat-capable in this schema layer.
|
||||||
|
|
||||||
## Identity And Reader Dispatch
|
## Identity And Reader Dispatch
|
||||||
|
|
||||||
`records_from_payload()` selects a reader from an explicit `vendor`, or from `detect_vendor(base_url, endpoint_kind)` when no vendor is supplied.
|
`records_from_payload()` selects a reader from an explicit `vendor`, or from
|
||||||
|
`detect_vendor(base_url, endpoint_kind)` when no vendor is supplied.
|
||||||
|
|
||||||
Current detection order and behavior are:
|
Current detection order and behavior are:
|
||||||
|
|
||||||
@@ -80,7 +94,11 @@ Current detection order and behavior are:
|
|||||||
|
|
||||||
These are normalization hints, not authorization. Hostname checks accept an exact domain or its dot-delimited subdomains after lowercasing and removing a trailing dot, so names such as `notopenai.com` do not match `openai.com`; local-port mappings remain intentionally covered by tests. Callers must not treat any result as proof of endpoint trust.
|
These are normalization hints, not authorization. Hostname checks accept an exact domain or its dot-delimited subdomains after lowercasing and removing a trailing dot, so names such as `notopenai.com` do not match `openai.com`; local-port mappings remain intentionally covered by tests. Callers must not treat any result as proof of endpoint trust.
|
||||||
|
|
||||||
Implemented reader modules are `generic_openai`, `openai`, `openrouter`, `google`, `llamacpp`, `ollama`, and `lmstudio`. Anthropic, Hugging Face, SGLang, and vLLM have placeholder vendor IDs but currently dispatch through the generic identity-only reader. Other explicitly supplied vendor strings are also preserved while using that generic reader.
|
Implemented reader modules are `generic_openai`, `openai`, `openrouter`,
|
||||||
|
`google`, `llamacpp`, `ollama`, and `lmstudio`. Anthropic, Hugging Face,
|
||||||
|
SGLang, and vLLM have placeholder vendor IDs but currently dispatch through the
|
||||||
|
generic identity-only reader. Other explicitly supplied vendor strings are
|
||||||
|
also preserved while using that generic reader.
|
||||||
|
|
||||||
Stable model identity is scoped in this order:
|
Stable model identity is scoped in this order:
|
||||||
|
|
||||||
@@ -90,9 +108,15 @@ Stable model identity is scoped in this order:
|
|||||||
|
|
||||||
## Generic Identity-Only Contract
|
## Generic Identity-Only Contract
|
||||||
|
|
||||||
The generic reader accepts mapping payloads containing `data[]` or `models[]`. Each item must itself be a mapping and provide `id`, `name`, or `model`. Bare-list payloads and `key`/`slug`-only items are not accepted by the current implementation.
|
The generic reader accepts mapping payloads containing `data[]` or `models[]`.
|
||||||
|
Each item must itself be a mapping and provide `id`, `name`, or `model`.
|
||||||
|
Bare-list payloads and `key`/`slug`-only items are not accepted by the current
|
||||||
|
implementation.
|
||||||
|
|
||||||
The reader deliberately returns unknown family, modalities, capabilities, and controls. It preserves the raw item on the in-memory record but does not parse type/task fields, descriptions, ownership, supported-parameter lists, capability-looking booleans, or token limits.
|
The reader deliberately returns unknown family, modalities, capabilities, and
|
||||||
|
controls. It preserves the raw item on the in-memory record but does not parse
|
||||||
|
type/task fields, descriptions, ownership, supported-parameter lists,
|
||||||
|
capability-looking booleans, or token limits.
|
||||||
|
|
||||||
## Provider-Native Readers
|
## Provider-Native Readers
|
||||||
|
|
||||||
@@ -112,13 +136,22 @@ The reader deliberately returns unknown family, modalities, capabilities, and co
|
|||||||
served model. It records tool/streaming claims, explicit unsupported
|
served model. It records tool/streaming claims, explicit unsupported
|
||||||
vision/audio assertions, controls, and runtime/training/size limits.
|
vision/audio assertions, controls, and runtime/training/size limits.
|
||||||
|
|
||||||
Readers tolerate non-object entries and unknown fields where their helpers permit it. They do not infer authoritative capability from model IDs or display names.
|
Readers tolerate non-object entries and unknown fields where their helpers
|
||||||
|
permit it. They do not infer authoritative capability from model IDs or display
|
||||||
|
names.
|
||||||
|
|
||||||
## Evidence Semantics
|
## Evidence Semantics
|
||||||
|
|
||||||
The canonical vocabulary includes admin override, endpoint configuration, provider reader, Cookbook/Hugging Face, maintained registries, heuristic, probe, and unknown sources. It also defines explicit, provider-reported, registry, heuristic, and unknown confidence values.
|
The canonical vocabulary includes admin override, endpoint configuration,
|
||||||
|
provider reader, Cookbook/Hugging Face, maintained registries, heuristic,
|
||||||
|
probe, and unknown sources. It also defines explicit, provider-reported,
|
||||||
|
registry, heuristic, and unknown confidence values.
|
||||||
|
|
||||||
Those tokens make evidence representable; current `dev` does not implement a global precedence, merge, expiry, or conflict-resolution engine. Assertions generated by readers are usually `claimed`; a `CapabilityProbeResult` maps pass to verified, fail to unsupported, and partial to claimed at the scope carried by that object.
|
Those tokens make evidence representable; current `dev` does not implement a
|
||||||
|
global precedence, merge, expiry, or conflict-resolution engine. Assertions
|
||||||
|
generated by readers are usually `claimed`; a `CapabilityProbeResult` maps pass
|
||||||
|
to verified, fail to unsupported, and partial to claimed at the scope carried
|
||||||
|
by that object.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -132,8 +165,6 @@ Focused tests pin:
|
|||||||
- OpenRouter, Google, Ollama, LM Studio, and llama.cpp mappings;
|
- OpenRouter, Google, Ollama, LM Studio, and llama.cpp mappings;
|
||||||
- negative cases that avoid name-based media/capability inference.
|
- negative cases that avoid name-based media/capability inference.
|
||||||
|
|
||||||
The provider detector has narrow production consumers in `src/ai_interaction.py` and `routes/gallery/gallery_routes.py` for selecting OpenRouter media request behavior. `ModelCapabilityRecord` normalization remains disconnected from runtime model discovery and request builders; helper reuse must not be described as full canonical integration.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Canonical records are not yet used by runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers.
|
- Canonical records are not yet used by runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Provider Capability Specs
|
# Provider Capability Specs
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-14
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -54,7 +54,6 @@ Current reader dispatch does not infer a provider from payload shape. It uses an
|
|||||||
- [github-copilot.md](github-copilot.md): account model-list observations and required runtime headers.
|
- [github-copilot.md](github-copilot.md): account model-list observations and required runtime headers.
|
||||||
- [chatgpt-subscription.md](chatgpt-subscription.md): Codex model identity and Responses event shape.
|
- [chatgpt-subscription.md](chatgpt-subscription.md): Codex model identity and Responses event shape.
|
||||||
- [cohere.md](cohere.md): native endpoint/catalog observations; not currently normalized.
|
- [cohere.md](cohere.md): native endpoint/catalog observations; not currently normalized.
|
||||||
- [featherless.md](featherless.md): plan-filtered catalog search and explicit picker pins without bulk discovery.
|
|
||||||
|
|
||||||
### Other provider identity and general/identity-only observations
|
### Other provider identity and general/identity-only observations
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
# ChatGPT Subscription Provider Shape
|
# ChatGPT Subscription Provider Shape
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-15
|
Last updated: dev@e57f60b | 2026-07-20
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Canonical provider ID `chatgpt_subscription`; Codex Responses transport; auth and runtime code in `src/chatgpt_subscription.py`, `routes/chatgpt_subscription_routes.py`, and `src/llm_core.py`. There is no dedicated ChatGPT Subscription canonical reader on current `dev`.
|
Canonical provider ID `chatgpt_subscription`; Codex Responses transport;
|
||||||
|
auth and runtime code in `src/chatgpt_subscription.py`,
|
||||||
|
`routes/chatgpt_subscription_routes.py`, and `src/llm_core.py`.
|
||||||
|
There is no dedicated ChatGPT Subscription canonical reader on current `dev`.
|
||||||
|
|
||||||
## Catalog Shape
|
## Catalog Shape
|
||||||
|
|
||||||
The account-scoped Codex models endpoint returns root `models[]`; `slug` is the request identity and `visibility`/`priority` control availability/order. These fields do not prove tools, reasoning, vision, or context. Null/malformed model lists fail soft rather than crashing discovery (#5280/#5281).
|
The account-scoped Codex models endpoint returns root `models[]`; `slug` is the
|
||||||
|
request identity and `visibility`/`priority` control availability/order. These
|
||||||
|
fields do not prove tools, reasoning, vision, or context. Null/malformed model
|
||||||
|
lists fail soft rather than crashing discovery (#5280/#5281).
|
||||||
|
|
||||||
The canonical generic reader does not accept `slug`-only items, so this runtime catalog is not currently normalized into `ModelCapabilityRecord` values.
|
The canonical generic reader does not accept `slug`-only items, so this runtime
|
||||||
|
catalog is not currently normalized into `ModelCapabilityRecord` values.
|
||||||
|
|
||||||
## Request And Response Shape
|
## Request And Response Shape
|
||||||
|
|
||||||
Transport uses an inference-only ChatGPT backend Responses payload allowlist: model, instructions, input, stream, store, optional temperature and reasoning effort. It drops native tools, tool choice, previous-response/background fields and unrelated options. `build_responses_input()` flattens conversation history into text input/output items; multipart image blocks are not preserved. Native tool calls/results and encrypted typed reasoning continuity are not preserved as a general protocol. Agent tools therefore use the text protocol on this provider rather than a claimed native function-call continuation.
|
Transport uses a ChatGPT backend Responses endpoint, `input` items, flattened
|
||||||
|
function tools, streamed function-call argument events, exact `call_id`, and
|
||||||
|
`function_call_output` continuation. Parallel calls and encrypted reasoning
|
||||||
|
continuity require preserving typed output/history rather than coercing all
|
||||||
|
roles to text. This shape is supported by the existing adapter and the focused
|
||||||
|
tool-calling follow-up evidence in #5490; unmerged observations remain claimed
|
||||||
|
until integrated/reproduced.
|
||||||
|
|
||||||
OAuth/device credentials and refresh are provider-session behavior. Expired credentials should return an actionable reconnect error, not generic model failure.
|
OAuth/device credentials and refresh are provider-session behavior. Expired
|
||||||
|
credentials should return an actionable reconnect error, not generic model
|
||||||
|
failure.
|
||||||
|
|
||||||
## Fallback And Safety
|
## Fallback And Safety
|
||||||
|
|
||||||
Only the explicit internal base/ChatGPT host selects this provider. Never send subscription credentials to a custom OpenAI-compatible URL. Catalog slugs alone remain identity-only. Runtime catalog metadata can retain advertised reasoning levels, but the runtime also supplies maintained defaults and model-name fallback metadata; those are not canonical verified capability cards.
|
Only the explicit internal base/ChatGPT host selects this provider. Never send
|
||||||
|
subscription credentials to a custom OpenAI-compatible URL. Catalog slugs stay
|
||||||
## Account And Usage Ownership
|
identity-only unless account-scoped fields or probes supply capability.
|
||||||
|
|
||||||
Device-flow setup supports multiple accounts using stable provider-auth IDs; display labels are not identity. Reconnect, delete, inventory and account usage are owner-scoped. Subscription endpoint inventory applies exact effective-owner equality even for admins, and refresh deduplication includes the provider-auth ID to keep accounts separate.
|
|
||||||
|
|
||||||
`GET /api/chatgpt-subscription/accounts/{auth_id}/usage` returns normalized rate-limit telemetry for exactly one owned auth session. `src.chatgpt_subscription.UsageCache` keeps deep-copied normalized snapshots for 45 seconds, bounded to 256 auth-id entries with FIFO eviction rather than single-flight refresh, and ownership is checked before cache hits. Forced refresh replaces the snapshot; reconnect/deletion invalidates cached usage. Errors return credential-free unavailable payloads. `static/js/chatgptSubscriptionUsage.js` owns the frontend read/display flow.
|
|
||||||
|
|
||||||
Runtime catalog metadata retains supported/default reasoning levels and is exposed through `/api/models` model metadata. `validate_reasoning_effort()` omits unsupported overrides; its maintained defaults and name-based fallback are runtime compatibility rules rather than canonical provider evidence.
|
|
||||||
|
|
||||||
## Binding And Telemetry Limits
|
|
||||||
|
|
||||||
New account setup creates a distinct auth/endpoint pair. Sessions persist endpoint id so the bound endpoint wins over labels; removed or disabled bound endpoints fail closed rather than borrowing another login. Legacy unbound sessions can bind the oldest enabled owner-visible URL-matching endpoint before resolving its exact owner-scoped auth session. Runtime bearer credentials are attached request-locally only to the registered subscription route. Forked session account identity and usage cache ownership remain independent of chat approval grants.
|
|
||||||
|
|
||||||
Usage parsing bounds finite numeric telemetry, preserves missing values as unknown and shapes credential-free errors; a JWT claim can supply unverified routing metadata without becoming verified identity. Usage cache and optimistic UI expansion do not guarantee fresh quota or live provider acceptance. Session `effort:<level>` is validated on model changes/partial generation settings, and reasoning effort partitions inference cache identity; metadata defaults/name fallbacks remain compatibility evidence.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Featherless Provider Shape
|
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-15
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Runtime provider ID `featherless`; OpenAI-compatible calls in `src/llm_core.py`, catalog and picker ownership in `routes/model_routes.py`, and setup/selection UI in `static/js/admin.js` and `static/js/providers.js`. No dedicated canonical capability reader exists.
|
|
||||||
|
|
||||||
## Catalog And Picker
|
|
||||||
|
|
||||||
Normal endpoint probing returns no bulk Featherless inventory. New rows use API endpoint kind and an explicit empty pin list; only selected model IDs become picker inventory. Admin `GET /api/model-endpoints/{ep_id}/catalog-search` queries `/v1/models` with `q`, `available_on_current_plan=true`, `status=active`, `conversational=true`, and bounded `page`/`per_page` (maximum 100). Its in-memory cache lasts 45 seconds and keys endpoint ID, normalized query and pagination. Upstream authorization, rate-limit and timeout failures become route errors rather than fabricated model cards.
|
|
||||||
|
|
||||||
Reachability tries `/v1/plan` and then a one-item plan-filtered model query. Endpoint status and selected model inventory are distinct facts; a reachable account need not have any pinned models.
|
|
||||||
|
|
||||||
## Request And Capability Boundaries
|
|
||||||
|
|
||||||
Calls use the OpenAI-compatible runtime adapter. Model availability on the current plan does not prove native tools, reasoning, modality, or context. Per-model endpoint tool modes are operator configuration; canonical generic normalization remains identity-only.
|
|
||||||
|
|
||||||
## Selection UI Limits
|
|
||||||
|
|
||||||
The admin picker debounces queries after two characters, pages bounded catalog results and saves pins/tool modes optimistically. Initial search cancellation does not establish equivalent load-more generation guards, and ignored PATCH response status does not prove accepted persistence. Lowercased query cache identity, explicit empty pins and endpoint reachability remain separate from provider plan/capability acceptance.
|
|
||||||
|
|
||||||
## Current Gaps
|
|
||||||
|
|
||||||
- No native canonical reader maps plan/catalog evidence into capability records.
|
|
||||||
- Catalog search is a live provider dependency; cache and pins do not guarantee continued account availability.
|
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
# llama.cpp Provider Shape
|
# llama.cpp Provider Shape
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-14
|
Last updated: dev@e57f60b | 2026-07-20
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages compatibility plus native server metadata; reader `src/model_capability_readers/llamacpp.py`.
|
Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages
|
||||||
|
compatibility plus native server metadata; reader
|
||||||
|
`src/model_capability_readers/llamacpp.py`.
|
||||||
|
|
||||||
## Metadata Shapes
|
## Metadata Shapes
|
||||||
|
|
||||||
`/v1/models` provides served identity and can include server model entries; native `/props` is authoritative for the running model/server combination:
|
`/v1/models` provides served identity and can include server model entries;
|
||||||
|
native `/props` is authoritative for the running model/server combination:
|
||||||
|
|
||||||
- `model_alias`/`model_path`;
|
- `model_alias`/`model_path`;
|
||||||
- `default_generation_settings.n_ctx` and sampling `params`;
|
- `default_generation_settings.n_ctx` and sampling `params`;
|
||||||
@@ -17,17 +20,25 @@ Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages c
|
|||||||
- `modalities.vision|audio`;
|
- `modalities.vision|audio`;
|
||||||
- current server/build state.
|
- current server/build state.
|
||||||
|
|
||||||
Capability depends on weights, projection/model assets, chat template, parser, and launch flags. It is endpoint evidence, not a checkpoint-name claim. `/props` and `/v1/models` can be merged only for the same served identity.
|
Capability depends on weights, projection/model assets, chat template, parser,
|
||||||
|
and launch flags. It is endpoint evidence, not a checkpoint-name claim.
|
||||||
|
`/props` and `/v1/models` can be merged only for the same served identity.
|
||||||
|
|
||||||
## Request And Response Shape
|
## Request And Response Shape
|
||||||
|
|
||||||
llama-server supports several OpenAI-compatible tasks and native extensions. Do not infer embeddings/rerank/chat solely from the OpenAI model card; use an explicit server model capability field or endpoint configuration. Tool and reasoning correctness can depend on selected chat template and parser.
|
llama-server supports several OpenAI-compatible tasks and native extensions.
|
||||||
|
Do not infer embeddings/rerank/chat solely from the OpenAI model card; use an
|
||||||
|
explicit server model capability field or endpoint configuration. Tool and
|
||||||
|
reasoning correctness can depend on selected chat template and parser.
|
||||||
|
|
||||||
## Fallback And Safety
|
## Fallback And Safety
|
||||||
|
|
||||||
The registry selects llama.cpp through an explicit vendor or endpoint kind; it does not auto-detect `/props` from payload shape. Port 8000 currently maps to the vLLM placeholder, while 8080 falls through to generic OpenAI-compatible. llama.cpp-only `session_id` and `cache_prompt` affinity fields must remain local endpoint behavior and never leak to strict cloud providers (#4640 and current affinity tests).
|
The registry selects llama.cpp through an explicit vendor or endpoint kind; it
|
||||||
|
does not auto-detect `/props` from payload shape. Port 8000 currently maps to
|
||||||
Runtime context lookup in `src/model_context.py` tries `/slots` first and falls back to `/props.default_generation_settings.n_ctx`, covering servers launched without `--slots`. This runtime path remains separate from canonical reader merging.
|
the vLLM placeholder, while 8080 falls through to generic OpenAI-compatible.
|
||||||
|
llama.cpp-only `session_id` and `cache_prompt` affinity fields must remain local
|
||||||
|
endpoint behavior and never leak to strict cloud providers (#4640 and current
|
||||||
|
affinity tests).
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
|
|||||||
+21
-8
@@ -1,12 +1,19 @@
|
|||||||
# Model Behavior Observations
|
# Model Behavior Observations
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-14
|
Last updated: dev@2e2bb52 | 2026-08-16
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This file records model- or provider+model-specific behavior observed in Odysseus code, tests, Issues, PRs, commits, and provider documentation. It is a compact evidence map, not a runtime matcher. General canonical rules belong in [model-capability-canonical.md](model-capability-canonical.md); provider-wide transport belongs in [the provider map](model-providers/_readme.md).
|
This file records model- or provider+model-specific behavior observed in
|
||||||
|
Odysseus code, tests, Issues, PRs, commits, and provider documentation. It is a
|
||||||
|
compact evidence map, not a runtime matcher. General canonical rules belong in
|
||||||
|
[model-capability-canonical.md](model-capability-canonical.md); provider-wide
|
||||||
|
transport belongs in [the provider map](model-providers/_readme.md).
|
||||||
|
|
||||||
The canonical capability layer intentionally has no `src/model_behavior_quirks.py`. Adding a registry before runtime call sites carry structured provider, model, version, and dialect identity would create another model-name matching layer.
|
The canonical capability layer intentionally has no
|
||||||
|
`src/model_behavior_quirks.py`.
|
||||||
|
Adding a registry before runtime call sites carry structured provider, model,
|
||||||
|
version, and dialect identity would create another model-name matching layer.
|
||||||
|
|
||||||
## General Observation Template
|
## General Observation Template
|
||||||
|
|
||||||
@@ -20,7 +27,9 @@ Record only the fields supported by the evidence:
|
|||||||
- source, confidence, status, and reproduction date;
|
- source, confidence, status, and reproduction date;
|
||||||
- whether the behavior is already implemented in runtime code.
|
- whether the behavior is already implemented in runtime code.
|
||||||
|
|
||||||
If exact structured identity is unavailable, keep the observation here and in its current tested runtime location. Do not promote it through substring, regex, prose, or serialized-prompt parsing in the canonical layer.
|
If exact structured identity is unavailable, keep the observation here and in
|
||||||
|
its current tested runtime location. Do not promote it through substring,
|
||||||
|
regex, prose, or serialized-prompt parsing in the canonical layer.
|
||||||
|
|
||||||
## Model-Specific Observation Map
|
## Model-Specific Observation Map
|
||||||
|
|
||||||
@@ -34,7 +43,9 @@ If exact structured identity is unavailable, keep the observation here and in it
|
|||||||
| Ollama native `gpt-oss` reasoning level | `gpt-oss` served through Ollama native | `think` accepts low/medium/high and does not represent off | provider docs; deployment scoped |
|
| Ollama native `gpt-oss` reasoning level | `gpt-oss` served through Ollama native | `think` accepts low/medium/high and does not represent off | provider docs; deployment scoped |
|
||||||
| Ollama compatibility disable observation | Ollama 0.20.6+, observed Qwen3.5 compatibility path | `reasoning_effort: none` was reported to disable reasoning | #5503; unmerged/low confidence until reproduced |
|
| Ollama compatibility disable observation | Ollama 0.20.6+, observed Qwen3.5 compatibility path | `reasoning_effort: none` was reported to disable reasoning | #5503; unmerged/low confidence until reproduced |
|
||||||
|
|
||||||
Issue and commit references are evidence identifiers, not runtime dependencies. Open or unmerged observations remain provisional until reproduced or supported by current provider documentation.
|
Issue and commit references are evidence identifiers, not runtime dependencies.
|
||||||
|
Open or unmerged observations remain provisional until reproduced or supported
|
||||||
|
by current provider documentation.
|
||||||
|
|
||||||
## Other Model-Level Observations
|
## Other Model-Level Observations
|
||||||
|
|
||||||
@@ -62,11 +73,13 @@ Issue and commit references are evidence identifiers, not runtime dependencies.
|
|||||||
#1704, #1478). Native engine metadata or a verified endpoint probe outranks
|
#1704, #1478). Native engine metadata or a verified endpoint probe outranks
|
||||||
a model-family name list.
|
a model-family name list.
|
||||||
|
|
||||||
Current `src.model_profiles` is a separate runtime schema/thinking profile helper: bounded `odysseus`/`ajax` tokens in the model ID leaf select compact schema and progressive thinking. It is not the absent canonical behavior registry. `src.utility_effort` validates utility effort against ChatGPT metadata that includes maintained/model-name defaults; callers must not describe those defaults as verified provider capability.
|
|
||||||
|
|
||||||
## Promotion Gate
|
## Promotion Gate
|
||||||
|
|
||||||
Before an observation becomes canonical runtime behavior, a consumer must already have the necessary structured identity and tests must cover both its positive scope and a neighboring negative scope. Request control and response visibility remain separate: hiding reasoning text is not the same as disabling reasoning at the provider (#2905).
|
Before an observation becomes canonical runtime behavior, a consumer must
|
||||||
|
already have the necessary structured identity and tests must cover both its
|
||||||
|
positive scope and a neighboring negative scope. Request control and response
|
||||||
|
visibility remain separate: hiding reasoning text is not the same as disabling
|
||||||
|
reasoning at the provider (#2905).
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
|
|||||||
+19
-24
@@ -1,6 +1,6 @@
|
|||||||
# Persistence
|
# Persistence
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -13,7 +13,8 @@ This spec covers durable state in:
|
|||||||
- `core/models.py`;
|
- `core/models.py`;
|
||||||
- `core/session_manager.py`;
|
- `core/session_manager.py`;
|
||||||
- `core/atomic_io.py`;
|
- `core/atomic_io.py`;
|
||||||
- `src/attachment_refs.py`, `src/upload_handler.py`, and `routes/upload_routes.py` for durable upload references and retention;
|
- `src/attachment_refs.py`, `src/upload_handler.py`, and
|
||||||
|
`routes/upload_routes.py` for durable upload references and retention;
|
||||||
- JSON stores managed by `core/auth.py`, `src/settings.py`, `src/api_key_manager.py`, `src/preset_manager.py`, `src/integrations.py`, `src/upload_handler.py`, `src/personal_docs.py`, `src/research_handler.py`, `src/bg_jobs.py`, `routes/prefs_routes.py`, canonical `routes/contacts/contacts_routes.py` and `routes/vault/vault_routes.py` plus their shims, `routes/cookbook_routes.py`, and memory/skills managers;
|
- JSON stores managed by `core/auth.py`, `src/settings.py`, `src/api_key_manager.py`, `src/preset_manager.py`, `src/integrations.py`, `src/upload_handler.py`, `src/personal_docs.py`, `src/research_handler.py`, `src/bg_jobs.py`, `routes/prefs_routes.py`, canonical `routes/contacts/contacts_routes.py` and `routes/vault/vault_routes.py` plus their shims, `routes/cookbook_routes.py`, and memory/skills managers;
|
||||||
- canonical `routes/email/email_helpers.py` scheduled-email storage;
|
- canonical `routes/email/email_helpers.py` scheduled-email storage;
|
||||||
- `routes/backup_routes.py` and `scripts/odysseus-backup`;
|
- `routes/backup_routes.py` and `scripts/odysseus-backup`;
|
||||||
@@ -23,9 +24,15 @@ This spec covers durable state in:
|
|||||||
|
|
||||||
`core/database.py` owns SQLAlchemy models and startup migrations. `src/database.py` is a compatibility re-export for legacy imports. Route and service code commonly owns its own `SessionLocal()` lifecycle instead of using one central unit-of-work wrapper.
|
`core/database.py` owns SQLAlchemy models and startup migrations. `src/database.py` is a compatibility re-export for legacy imports. Route and service code commonly owns its own `SessionLocal()` lifecycle instead of using one central unit-of-work wrapper.
|
||||||
|
|
||||||
The default database is SQLite at `DATA_DIR/app.db`. `src.runtime_paths` and `src.constants` own the data-dir default: source runs use the repository `data/` directory, frozen builds default to `~/.odysseus/data`, and `ODYSSEUS_DATA_DIR` overrides both. The core requirements include `psycopg2-binary` for PostgreSQL driver availability. SQLAlchemy can point at a non-SQLite `DATABASE_URL`, but current startup migrations/backfills are SQLite-first and often use `sqlite3`, `PRAGMA`, or SQLite catalog queries. External DBs are not fully migration-compatible unless those helpers are made backend-neutral.
|
The default database is SQLite at `DATA_DIR/app.db`. `src.runtime_paths` and `src.constants` own the data-dir default: source runs use the repository `data/` directory, frozen builds default to `~/.odysseus/data`, and `ODYSSEUS_DATA_DIR` overrides both. SQLAlchemy can point at a non-SQLite `DATABASE_URL`, but current startup migrations/backfills are SQLite-first and often use `sqlite3`, `PRAGMA`, or SQLite catalog queries. External DBs are not fully migration-compatible unless those helpers are made backend-neutral.
|
||||||
|
|
||||||
After `Base.metadata.create_all()`, `init_db()` resolves file-backed SQLite paths from SQLAlchemy's parsed engine URL and attempts to restrict the main database plus existing `-journal`, `-wal`, and `-shm` sidecars to `0600` on POSIX. Driver-qualified, query-tagged, and local `file:` URI forms are covered; non-SQLite, in-memory SQLite, and Windows paths are skipped. A failed POSIX chmod is logged because the database and sidecars can contain password/token hashes and encrypted provider material.
|
After `Base.metadata.create_all()`, `init_db()` resolves file-backed SQLite
|
||||||
|
paths from SQLAlchemy's parsed engine URL and attempts to restrict the main
|
||||||
|
database plus existing `-journal`, `-wal`, and `-shm` sidecars to `0600` on
|
||||||
|
POSIX. Driver-qualified, query-tagged, and local `file:` URI forms are covered;
|
||||||
|
non-SQLite, in-memory SQLite, and Windows paths are skipped. A failed POSIX
|
||||||
|
chmod is logged because the database and sidecars can contain password/token
|
||||||
|
hashes and encrypted provider material.
|
||||||
|
|
||||||
Timestamp defaults use `utcnow_naive()` so existing naive `DateTime` columns stay UTC without the deprecated `datetime.utcnow()` default.
|
Timestamp defaults use `utcnow_naive()` so existing naive `DateTime` columns stay UTC without the deprecated `datetime.utcnow()` default.
|
||||||
|
|
||||||
@@ -37,15 +44,17 @@ Current model families include:
|
|||||||
- email accounts, model endpoints, MCP servers, comparisons;
|
- email accounts, model endpoints, MCP servers, comparisons;
|
||||||
- provider auth sessions for OAuth/device-flow-backed provider credentials;
|
- provider auth sessions for OAuth/device-flow-backed provider credentials;
|
||||||
- API tokens, admin-global webhooks, user tools/tool data, integrations;
|
- API tokens, admin-global webhooks, user tools/tool data, integrations;
|
||||||
- crew members, scheduled tasks, task runs, notification logs, background tool jobs, notes;
|
- crew members, scheduled tasks, task runs, notes;
|
||||||
- memory rows, calendar calendars, calendar events, and email-calendar invitation revision/tombstone state.
|
- memory rows, calendar calendars, and calendar events.
|
||||||
|
|
||||||
Chat persistence stores model-readable text plus compact attachment-reference lines in `chat_messages.content`, while structured references remain in message metadata. Provider data URLs used by the live turn are not duplicated into the durable transcript. The FTS migration recreates insert/update triggers to omit inline media and scrubs legacy indexed rows that still contain data URLs.
|
Chat persistence stores model-readable text plus compact attachment-reference
|
||||||
|
lines in `chat_messages.content`, while structured references remain in message
|
||||||
|
metadata. Provider data URLs used by the live turn are not duplicated into the
|
||||||
|
durable transcript. The FTS migration recreates insert/update triggers to omit
|
||||||
|
inline media and scrubs legacy indexed rows that still contain data URLs.
|
||||||
|
|
||||||
Current calendar/task persistence includes CalDAV remote identity columns (`CalendarCal.remote_href`, `CalendarCal.remote_etag`, `CalendarEvent.remote_href`, `CalendarEvent.remote_etag`), `CalendarEvent.caldav_sync_pending` for retryable writeback state, and `ScheduledTask.character_id` for built-in task persona selection.
|
Current calendar/task persistence includes CalDAV remote identity columns (`CalendarCal.remote_href`, `CalendarCal.remote_etag`, `CalendarEvent.remote_href`, `CalendarEvent.remote_etag`), `CalendarEvent.caldav_sync_pending` for retryable writeback state, and `ScheduledTask.character_id` for built-in task persona selection.
|
||||||
|
|
||||||
`Note.gallery_id` links drawing notes to stable gallery images. `CalendarEvent` source-email identifiers preserve invitation provenance; `EmailCalendarInvitation` keeps owner/sender/source/recurrence revision and cancellation state for replay decisions.
|
|
||||||
|
|
||||||
`EmailAccount` includes encrypted password fields plus Google OAuth fields (`oauth_provider`, encrypted access/refresh tokens, token expiry) and optional `display_name`. Startup migrations add those OAuth/display columns idempotently for older databases.
|
`EmailAccount` includes encrypted password fields plus Google OAuth fields (`oauth_provider`, encrypted access/refresh tokens, token expiry) and optional `display_name`. Startup migrations add those OAuth/display columns idempotently for older databases.
|
||||||
|
|
||||||
Email default-account state is serialized per owner. Startup normalizes legacy duplicate defaults and installs a per-owner unique default constraint/index; create, delete/promotion, set-default, demo teardown, and user rename perform their default transition in one locked transaction. Multi-owner rename locks are acquired in canonical order, so a stale concurrent default mutation fails closed instead of recreating multiple defaults.
|
Email default-account state is serialized per owner. Startup normalizes legacy duplicate defaults and installs a per-owner unique default constraint/index; create, delete/promotion, set-default, demo teardown, and user rename perform their default transition in one locked transaction. Multi-owner rename locks are acquired in canonical order, so a stale concurrent default mutation fails closed instead of recreating multiple defaults.
|
||||||
@@ -118,20 +127,6 @@ Docker bind-mounts `data/`, `logs/`, cache/local state, and optional Chroma stat
|
|||||||
|
|
||||||
ChromaDB/vector stores are optional durable storage outside `data/app.db`; missing Chroma degrades RAG, memory-vector, and tool-index features without blocking core SQLite/JSON persistence. Vector collections can be lane-suffixed for custom HTTP embeddings versus FastEmbed fallback. See `documents-rag-uploads.md`.
|
ChromaDB/vector stores are optional durable storage outside `data/app.db`; missing Chroma degrades RAG, memory-vector, and tool-index features without blocking core SQLite/JSON persistence. Vector collections can be lane-suffixed for custom HTTP embeddings versus FastEmbed fallback. See `documents-rag-uploads.md`.
|
||||||
|
|
||||||
## Run Evidence And New Durable State
|
|
||||||
|
|
||||||
`Session` adds `cwd`, endpoint id, memory-extraction/injection and skill-injection toggles, thinking mode, temperature/max-token overrides, and cumulative cost. `ModelEndpoint.model_tool_modes` stores per-model tool-schema preferences. `ScheduledTask.request_authority_json` stores the server-only admitted request snapshot. Startup migration helpers add these columns to legacy schemas. `BackgroundToolJob` holds origin owner/session/tool/query, status, payload/summary, and delivered message id; `NotificationLog` persists owner-attributed task completion/error notification text.
|
|
||||||
|
|
||||||
`src.agent_runtime.effect_log` stores append-only claims, outcomes, and observations in `DATA_DIR/effects/<root-run-id>.jsonl`, with launch-generation indexes alongside them. Claims are fsynced before backend invocation; persistence failure refuses execution. Reload appends interrupted outcomes for unsettled non-running claims, while eligible owned `RUNNING` process claims survive for background lifecycle settlement; it never manufactures success or fresh state. Appends merge durable history under an advisory lock before allocating positions. Cross-process locking and directory-fsync guarantees are POSIX-specific; other platforms have narrower guarantees. Claim files use restrictive creation, no-follow/regular-file/single-link checks, strict row validation and a tolerated torn final tail. Non-claim persistence can degrade to volatile evidence; durable claim failure still refuses invocation. Advisory-lock append merges durable rows, allocates positions and repairs only a torn tail; reader locking does not silently repair. Launch indexes use fsync/replace ordering, with narrower non-POSIX guarantees. These checks do not imply authenticated tamper-proof logs, constant-cost growth or identical index hardlink checks. This store is evidence, not operation authority.
|
|
||||||
|
|
||||||
`data/containment_grants.json` and `data/bg_jobs.json` preserve containment/process identity across restart. Browser producer state resolves through `BROWSER_RESOURCES_DIR` in shared constants. These server control-plane stores are denied to native model filesystem operations; ownership and runtime integrity must remain separate from editable user artifacts.
|
|
||||||
|
|
||||||
## Cleanup Counts And Chroma Volumes
|
|
||||||
|
|
||||||
`src/cleanup_service.py` derives preview and deletion eligibility from durable message rows, not the session counter. Recent-session protection uses a limited ID query; final DELETE rechecks owner/archive/age/importance/message threshold. DELETE RETURNING identifies cleanup-owned deletions where supported; legacy SQLite uses rowcount plus survivor queries, with estimated space attribution bounded to the deleted count. Session-cache eviction occurs after commit.
|
|
||||||
|
|
||||||
The standard, AMD and NVIDIA Compose files mount `chromadb-data` at `/data`; native Chroma uses `data/chroma/`. Pre-fix Docker stores may have live data in the old container writable layer despite an empty volume at `/chroma/chroma`; recreation can destroy that layer. `docs/chromadb-volume-migration.md` documents cold rescue, image preservation, empty-volume seeding and staging/count verification before cutover. This is an operator procedure, not startup migration or proof of data recovery.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Migration behavior is centralized but long and manual.
|
- Migration behavior is centralized but long and manual.
|
||||||
@@ -139,4 +134,4 @@ The standard, AMD and NVIDIA Compose files mount `chromadb-data` at `/data`; nat
|
|||||||
- Some JSON store shapes are only documented by manager code and tests.
|
- Some JSON store shapes are only documented by manager code and tests.
|
||||||
- Startup migrations lack a legacy-schema/idempotence test harness for owner backfills, encrypted-secret rewrites, and repeated runs.
|
- Startup migrations lack a legacy-schema/idempotence test harness for owner backfills, encrypted-secret rewrites, and repeated runs.
|
||||||
- JSON-store atomicity is inconsistent across stores, though shared atomic writers, upload metadata recovery, prefs, and strict memory mutations now have focused coverage.
|
- JSON-store atomicity is inconsistent across stores, though shared atomic writers, upload metadata recovery, prefs, and strict memory mutations now have focused coverage.
|
||||||
- New local stores require explicit ownership, backup, and control-plane access decisions; a filesystem root alone does not grant access to server state.
|
- Agent filesystem tools currently allow broad `data/` access; secret-bearing files under `data/` need explicit deny coverage.
|
||||||
|
|||||||
+11
-11
@@ -1,6 +1,6 @@
|
|||||||
# Research
|
# Research
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -50,8 +50,8 @@ Agent tools and the CLI read and mutate persisted research JSON directly. They a
|
|||||||
`src.deep_research.DeepResearcher` owns multi-round research work:
|
`src.deep_research.DeepResearcher` owns multi-round research work:
|
||||||
|
|
||||||
- date/context setup;
|
- date/context setup;
|
||||||
- search provider selection and fallback through `src.research_navigator.ResearchNavigator`, which dispatches the shared provider chain;
|
- search provider selection and fallback through `src.search.providers` and `src.search.core`;
|
||||||
- URL/content fetching through `ResearchNavigator.fetch()` and the canonical service content fetcher, with structured unavailable results for disabled private-browser reads;
|
- URL/content fetching through `src.search.fetch_webpage_content`;
|
||||||
- separate tracking of analyzed URLs, last search errors, and empty-round limits;
|
- separate tracking of analyzed URLs, last search errors, and empty-round limits;
|
||||||
- source summarization/extraction;
|
- source summarization/extraction;
|
||||||
- synthesis into final answers/reports;
|
- synthesis into final answers/reports;
|
||||||
@@ -80,7 +80,12 @@ Spinoff/Discuss creates a new chat session from a saved report. It seeds the rep
|
|||||||
|
|
||||||
Research persistence uses `data/deep_research/<session_id>.json`. Current JSON can include result/report text, raw report, sources, raw findings, stats, category, archived state, hidden images, owner, timestamps, and consumed state.
|
Research persistence uses `data/deep_research/<session_id>.json`. Current JSON can include result/report text, raw report, sources, raw findings, stats, category, archived state, hidden images, owner, timestamps, and consumed state.
|
||||||
|
|
||||||
Route access to persisted report files is path-confined. Browser routes validate session ids against `^[a-zA-Z0-9-]{1,128}$`, enumerate trusted `*.json` files under the resolved research storage root, match by exact filename, reject symlink/path escapes after `resolve().relative_to(root)`, and then perform owner checks before detail/archive/delete/result-peek/spinoff reads or mutations. Invalid ids return 400; missing or cross-owner reports return 404.
|
Route access to persisted report files is path-confined. Browser routes validate
|
||||||
|
session ids against `^[a-zA-Z0-9-]{1,128}$`, enumerate trusted `*.json` files
|
||||||
|
under the resolved research storage root, match by exact filename, reject
|
||||||
|
symlink/path escapes after `resolve().relative_to(root)`, and then perform owner
|
||||||
|
checks before detail/archive/delete/result-peek/spinoff reads or mutations.
|
||||||
|
Invalid ids return 400; missing or cross-owner reports return 404.
|
||||||
|
|
||||||
`src.visual_report` owns HTML report generation from markdown-like research output, heading/TOC processing, category styling, image injection, allowlist sanitization of untrusted rendered HTML, and client-side controls for hiding images and discussing reports.
|
`src.visual_report` owns HTML report generation from markdown-like research output, heading/TOC processing, category styling, image injection, allowlist sanitization of untrusted rendered HTML, and client-side controls for hiding images and discussing reports.
|
||||||
|
|
||||||
@@ -114,7 +119,8 @@ The active FastAPI app path uses `src.research_handler.ResearchHandler`.
|
|||||||
|
|
||||||
`services/research/service.py` is a public wrapper around a duplicate `services.research.research_handler.ResearchHandler`. That services handler remains compatibility/cleanup surface rather than canonical runtime truth; check parity before assuming it has every active-route field or policy behavior.
|
`services/research/service.py` is a public wrapper around a duplicate `services.research.research_handler.ResearchHandler`. That services handler remains compatibility/cleanup surface rather than canonical runtime truth; check parity before assuming it has every active-route field or policy behavior.
|
||||||
|
|
||||||
Its source extraction skips non-dict finding rows so one malformed cached or generated entry does not discard later valid URL/title/summary sources.
|
Its source extraction skips non-dict finding rows so one malformed cached or
|
||||||
|
generated entry does not discard later valid URL/title/summary sources.
|
||||||
|
|
||||||
Search compatibility also matters: `src.search.core`, `src.search.providers`, and `src.search.content` alias the service search path so old imports stay live without a second fetch implementation.
|
Search compatibility also matters: `src.search.core`, `src.search.providers`, and `src.search.content` alias the service search path so old imports stay live without a second fetch implementation.
|
||||||
|
|
||||||
@@ -139,12 +145,6 @@ Existing useful coverage includes deep-research runtime/degraded tests, handler/
|
|||||||
|
|
||||||
Coverage is still thin around live job route ownership, `/api/research/start` route behavior, SSE/result-peek/cancel edges, spinoff endpoint ownership, tool/CLI direct JSON access, remote-image policy, and frontend panel/jobs behavior.
|
Coverage is still thin around live job route ownership, `/api/research/start` route behavior, SSE/result-peek/cancel edges, spinoff endpoint ownership, tool/CLI direct JSON access, remote-image policy, and frontend panel/jobs behavior.
|
||||||
|
|
||||||
## Bounded Navigation And Evidence
|
|
||||||
|
|
||||||
`src.research_navigator.py` owns parsed `ResearchAction` requests, normalized `ResearchPage` responses, and source assessments. Search uses the shared provider chain off the event loop; ordinary fetch uses the canonical URL-safe content fetcher, while `browser_read` requests the private browser tool and currently receives the common denial for page actions. `DeepResearcher` chooses bounded navigation actions, records retrieval mode/provider use and source coverage, and can request a browser retry for weak fetches; the current disabled page-action policy prevents that retry from supplying page evidence.
|
|
||||||
|
|
||||||
Small local model heuristics select a simpler fact/research path with smaller planning calls. Findings-window synthesis and extraction concurrency/timeouts still bound work; useful source text remains a dependency; private-browser page actions are currently unavailable. Scheduled research binds sealed task authority around the handler call; direct handler/route behavior must not be assumed to share that scheduling wrapper. Saved reports retain gathered evidence and fallback reports when model synthesis cannot complete.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Consolidate, retire, or clearly deprecate `services/research/research_handler.py`.
|
- Consolidate, retire, or clearly deprecate `services/research/research_handler.py`.
|
||||||
|
|||||||
+14
-21
@@ -1,6 +1,6 @@
|
|||||||
# Runtime
|
# Runtime
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -13,10 +13,10 @@ This spec covers current app runtime wiring in:
|
|||||||
- `core/constants.py`;
|
- `core/constants.py`;
|
||||||
- `src/constants.py`;
|
- `src/constants.py`;
|
||||||
- `src/interactive_gate.py`;
|
- `src/interactive_gate.py`;
|
||||||
- `src/process_reaper.py`, `src/process_lifecycle.py`, and `src/process_ownership.py`;
|
|
||||||
- `src/host_docker_access.py`;
|
- `src/host_docker_access.py`;
|
||||||
- `core/middleware.py`;
|
- `core/middleware.py`;
|
||||||
- all route setup functions registered from `app.py`, including canonical `routes/admin_wipe/`, `routes/cleanup/`, `routes/compare/`, `routes/contacts/`, `routes/document/`, `routes/gallery/`, `routes/history/`, `routes/mcp/`, `routes/memory/`, `routes/note/`, `routes/research/`, `routes/search/`, `routes/task/`, `routes/vault/`, and `routes/webhook/` packages plus top-level compatibility shims;
|
- all route setup functions registered from `app.py`, including canonical
|
||||||
|
`routes/admin_wipe/`, `routes/cleanup/`, `routes/compare/`, `routes/contacts/`, `routes/document/`, `routes/gallery/`, `routes/history/`, `routes/mcp/`, `routes/memory/`, `routes/note/`, `routes/research/`, `routes/search/`, `routes/task/`, `routes/vault/`, and `routes/webhook/` packages plus top-level compatibility shims;
|
||||||
- `routes/prefs_routes.py`, `routes/workspace_routes.py`, and `companion/routes.py`;
|
- `routes/prefs_routes.py`, `routes/workspace_routes.py`, and `companion/routes.py`;
|
||||||
- `src/generated_images.py` for generated-media file resolution;
|
- `src/generated_images.py` for generated-media file resolution;
|
||||||
- `launcher.py`, `Odysseus.spec`, and platform launcher scripts where frozen/native startup changes runtime paths;
|
- `launcher.py`, `Odysseus.spec`, and platform launcher scripts where frozen/native startup changes runtime paths;
|
||||||
@@ -30,7 +30,9 @@ This spec covers current app runtime wiring in:
|
|||||||
|
|
||||||
`app.py` separately owns runtime singletons and integration hooks for auth, vector RAG, TTS/STT, webhooks, scheduled tasks, MCP, assistant log globals, event bus wiring, AI interaction globals, API-token cache invalidation, and foreground activity tracking. `src.runtime_paths` owns source-versus-frozen app/data path resolution; `src.constants` derives `DATA_DIR` from `ODYSSEUS_DATA_DIR` or that runtime default. `core/constants.py` and `src/constants.py` are both live import paths and are not fully identical today, so new constants need explicit placement/compatibility decisions.
|
`app.py` separately owns runtime singletons and integration hooks for auth, vector RAG, TTS/STT, webhooks, scheduled tasks, MCP, assistant log globals, event bus wiring, AI interaction globals, API-token cache invalidation, and foreground activity tracking. `src.runtime_paths` owns source-versus-frozen app/data path resolution; `src.constants` derives `DATA_DIR` from `ODYSSEUS_DATA_DIR` or that runtime default. `core/constants.py` and `src/constants.py` are both live import paths and are not fully identical today, so new constants need explicit placement/compatibility decisions.
|
||||||
|
|
||||||
The shared upload handler is also installed on the session manager and tool helper, and `app.py` injects it into attachment-bearing route factories so durable writers and cleanup use one lifecycle owner.
|
The shared upload handler is also installed on the session manager and tool
|
||||||
|
helper, and `app.py` injects it into attachment-bearing route factories so
|
||||||
|
durable writers and cleanup use one lifecycle owner.
|
||||||
|
|
||||||
## Routes And Static Serving
|
## Routes And Static Serving
|
||||||
|
|
||||||
@@ -44,9 +46,9 @@ Admin wipe, cleanup, compare, contacts, documents, gallery, history, MCP, memory
|
|||||||
|
|
||||||
The SPA routes `/`, `/notes`, `/calendar`, `/cookbook`, `/email`, `/memory`, `/gallery`, `/tasks`, and `/library` all serve `static/index.html`. `static/` is served with revalidation for `.js`, `.css`, and `.html` because the frontend ships raw browser modules with no hashed build output.
|
The SPA routes `/`, `/notes`, `/calendar`, `/cookbook`, `/email`, `/memory`, `/gallery`, `/tasks`, and `/library` all serve `static/index.html`. `static/` is served with revalidation for `.js`, `.css`, and `.html` because the frontend ships raw browser modules with no hashed build output.
|
||||||
|
|
||||||
Direct app-owned endpoints include `/api/generated-image/{filename}`, `/backgrounds`, `/login`, `/api/version`, `/api/health`, `/api/ready`, `/api/runtime`, and `/api/activity/heartbeat`. The `/backgrounds` prototype route points at the absent `static/backgrounds.html` and is auth-gated when authentication is enabled despite its handler docstring describing it as public. This remains known route/static drift at the checked revision.
|
Direct app-owned endpoints include `/api/generated-image/{filename}`, `/backgrounds`, `/login`, `/api/version`, `/api/health`, `/api/ready`, `/api/runtime`, and `/api/activity/heartbeat`. `/backgrounds` points at `static/backgrounds.html`; if that file is absent or the route remains auth-gated, that is route/static drift rather than an intentional public contract.
|
||||||
|
|
||||||
`/static/*` is auth-exempt and public. SPA HTML routes are auth-gated except `/login`, and they are nonce-injected dynamic `HTMLResponse` values outside the static mount. Generated images and videos are served from `data/generated_images` through the generated-image resolver with private, no-cache, must-revalidate/nosniff headers; access checks precede disk probing and database verification errors return 503 (see `gallery-editor-media.md`).
|
`/static/*` is auth-exempt and public. SPA HTML routes are auth-gated except `/login`, and they are nonce-injected dynamic `HTMLResponse` values outside the static mount. Generated images and videos are served from `data/generated_images` through the generated-image resolver with immutable/nosniff caching.
|
||||||
|
|
||||||
## Runtime Security Boundaries
|
## Runtime Security Boundaries
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ Security headers include HSTS and a restrictive `Permissions-Policy` that disabl
|
|||||||
|
|
||||||
`_TIMEOUT_EXEMPT_PREFIXES` owns hard-timeout bypass policy. It is prefix-based and currently exempts all subroutes under `/api/chat`, `/api/shell/stream`, `/api/research`, `/api/model/download`, `/api/model/probe`, `/api/model-endpoints`, `/api/cookbook/setup`, `/api/upload`, `/api/image`, and `/api/memory/audit`. Memory audit has its own longer inactivity timeout.
|
`_TIMEOUT_EXEMPT_PREFIXES` owns hard-timeout bypass policy. It is prefix-based and currently exempts all subroutes under `/api/chat`, `/api/shell/stream`, `/api/research`, `/api/model/download`, `/api/model/probe`, `/api/model-endpoints`, `/api/cookbook/setup`, `/api/upload`, `/api/image`, and `/api/memory/audit`. Memory audit has its own longer inactivity timeout.
|
||||||
|
|
||||||
Generated-image serving validates filename syntax before identity and access checks, then resolves the confined path and verifies file existence. With auth enabled, missing identity returns 401; recent registration, gallery ownership, linked-session ownership or an owned-chat metadata reference supplies the implemented access criterion. Missing or mismatched access returns 404, and database verification failures return 503. Explicit disabled-auth mode skips ownership verification. See `gallery-editor-media.md` for branch ordering and registry lifetime, and `auth-security.md` for `LOCALHOST_BYPASS`, internal-tool loopback, proxy-header exclusion, and owner-impersonation policy.
|
Generated-image path resolution fails closed for invalid names, path escape, and missing files. Ownership checks are best-effort when a current user exists: gallery rows owned by a different user return 404, rowless generated files are allowed, and DB/helper failures fail open. See `auth-security.md` for `LOCALHOST_BYPASS`, internal-tool loopback, proxy-header exclusion, and owner-impersonation policy.
|
||||||
|
|
||||||
## Runtime Behavior
|
## Runtime Behavior
|
||||||
|
|
||||||
@@ -68,11 +70,12 @@ Generated-image serving validates filename syntax before identity and access che
|
|||||||
|
|
||||||
## Lifespan Startup
|
## Lifespan Startup
|
||||||
|
|
||||||
Upload cleanup first snapshots durable chat, document, gallery, note, and calendar references and aborts on scan or upload-index integrity failure.
|
Upload cleanup first snapshots durable chat, document, gallery, note, and
|
||||||
|
calendar references and aborts on scan or upload-index integrity failure.
|
||||||
|
|
||||||
Startup purges leftover incognito sessions, reconciles default scheduled tasks before the task runner starts, and backfills legacy skill owners when possible.
|
Startup purges leftover incognito sessions, reconciles default scheduled tasks before the task runner starts, and backfills legacy skill owners when possible.
|
||||||
|
|
||||||
Startup fire-and-forget work includes upload cleanup, background-job monitoring, MCP built-in registration and user-server connection, tool-index warmup, model-endpoint warmup, endpoint keepalive, Cookbook serve lifecycle monitoring, and hourly null-owner sweeps. Skills Audit is scheduled per owner by `TaskScheduler`; app lifespan deliberately does not start a second ownerless audit. The in-process task scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`; email polling is started from email route setup and gated separately by `ODYSSEUS_INPROCESS_POLLERS`. Foreground-gate knobs are `BACKGROUND_TASK_FOREGROUND_GATE`, `BACKGROUND_TASK_QUIET_MS`, `BACKGROUND_TASK_MAX_WAIT_SECONDS`, and `BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS`.
|
Startup fire-and-forget work includes upload cleanup, background-job monitoring, MCP built-in registration and user-server connection, tool-index warmup, model-endpoint warmup, endpoint keepalive, Cookbook serve lifecycle monitoring, hourly null-owner sweeps, and nightly skill audit. The in-process task scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`; email polling is started from email route setup and gated separately by `ODYSSEUS_INPROCESS_POLLERS`. Foreground-gate knobs are `BACKGROUND_TASK_FOREGROUND_GATE`, `BACKGROUND_TASK_QUIET_MS`, `BACKGROUND_TASK_MAX_WAIT_SECONDS`, and `BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS`.
|
||||||
|
|
||||||
Shutdown cancels upload cleanup, stops the task scheduler, closes the webhook manager, and disconnects MCP servers.
|
Shutdown cancels upload cleanup, stops the task scheduler, closes the webhook manager, and disconnects MCP servers.
|
||||||
|
|
||||||
@@ -87,20 +90,10 @@ Shutdown cancels upload cleanup, stops the task scheduler, closes the webhook ma
|
|||||||
- `src.host_docker_access` treats host Docker access from inside the container as opt-in. Default Compose does not mount `/var/run/docker.sock`; `docker/host-docker.yml` plus `ODYSSEUS_ENABLE_HOST_DOCKER=true` are required before local container code considers the host Docker daemon available.
|
- `src.host_docker_access` treats host Docker access from inside the container as opt-in. Default Compose does not mount `/var/run/docker.sock`; `docker/host-docker.yml` plus `ODYSSEUS_ENABLE_HOST_DOCKER=true` are required before local container code considers the host Docker daemon available.
|
||||||
- Chroma-backed consumers degrade independently: personal-doc RAG can return route-level 503s, semantic memory vectors can be dropped from chat/memory wiring, and the tool index can fall back when vector retrieval is unavailable.
|
- Chroma-backed consumers degrade independently: personal-doc RAG can return route-level 503s, semantic memory vectors can be dropped from chat/memory wiring, and the tool index can fall back when vector retrieval is unavailable.
|
||||||
- RAG startup failure is throttled so failed clients do not poison later retries.
|
- RAG startup failure is throttled so failed clients do not poison later retries.
|
||||||
- MCP startup is asynchronous and non-critical. User-server connection is bounded, failures surface through MCP status routes, and unbound builtin MCP calls can reconnect after crashes; incarnation-bound calls return failure rather than silently replacing the backend.
|
- MCP startup is asynchronous and non-critical. User-server connection is bounded, failures surface through MCP status routes, and builtin MCP calls can reconnect after crashes.
|
||||||
- `/api/health` is liveness only. `src.readiness.check_readiness()` reports database reachability, writable data dir, informational local-first storage metadata and tool-index state. Database/data-dir checks gate `/api/ready`; `ODYSSEUS_REQUIRE_TOOL_INDEX_READY` additionally makes tool-index readiness critical when enabled. Default readiness does not certify RAG, Chroma, MCP, memory vectors or endpoint warmups.
|
- `/api/health` is liveness only. `/api/ready` checks database reachability, writable data dir, and local-first storage metadata; it does not prove optional subsystem health for RAG, Chroma, MCP, memory vectors, tool index, or endpoint warmups.
|
||||||
- `/api/diagnostics/services` is an admin diagnostics endpoint for optional service health. It reports bounded, non-intrusive checks for ChromaDB, SearXNG, email accounts, ntfy, and model provider endpoints with `ok`/`degraded`/`down`/`disabled` style status values and strips secret-bearing URLs/errors. `/api/diagnostics/logs` returns a bounded tail of the app log for admin troubleshooting.
|
- `/api/diagnostics/services` is an admin diagnostics endpoint for optional service health. It reports bounded, non-intrusive checks for ChromaDB, SearXNG, email accounts, ntfy, and model provider endpoints with `ok`/`degraded`/`down`/`disabled` style status values and strips secret-bearing URLs/errors. `/api/diagnostics/logs` returns a bounded tail of the app log for admin troubleshooting.
|
||||||
|
|
||||||
## Startup Process Reconciliation
|
|
||||||
|
|
||||||
Lifespan calls `src.process_reaper` once to reconcile persisted containment grants and detached job identities from previous server runs. Verified orphan processes under abandoned containment grants are torn down; owned detached jobs intentionally survive restart. Unverifiable containment grants remain active and unsignalled, while `src.bg_jobs.disown_unverified()` marks foreign/unverifiable running job records failed without signalling and preserves pending follow-up. Retiring a record does not establish process death. `src.process_lifecycle` owns identity checks, TERM/KILL escalation, and observed-death receipts; caller-specific containment/browser/Cookbook policy remains outside it.
|
|
||||||
|
|
||||||
## Process And Service-Home Limits
|
|
||||||
|
|
||||||
Native launch readiness is a bounded functional containment probe plus exact supervisor/worker acknowledgement, not executable presence. Startup reconciliation retains unverified ownership and running detached job generations instead of fabricating death or renewed permission. Background delivery revalidates linkage after asynchronous continuation and retires exact observed-dead launch records; invalidated jobs do not prevent registry cleanup during shutdown. Missing procfs degrades discovery/command-line evidence and does not authorize signaling.
|
|
||||||
|
|
||||||
`core.platform_compat.service_home()` uses Windows `Path.home()` without importing `pwd`. Successful POSIX real-UID passwd lookup ignores a child HOME override; a missing passwd entry or lookup OS error falls back to `Path.home()`, which can honor that override. Model-visible runtime/context metadata is bounded and untrusted; environment-key allowlists do not scrub explicitly supplied values. Readiness errors expose bounded error categories rather than raw exception details, and readiness still does not certify optional provider/browser/model health.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- `app.py` is still a large route registry and runtime orchestrator. There is no generated route manifest or smaller runtime composition layer yet.
|
- `app.py` is still a large route registry and runtime orchestrator. There is no generated route manifest or smaller runtime composition layer yet.
|
||||||
|
|||||||
+6
-20
@@ -1,6 +1,6 @@
|
|||||||
# Search
|
# Search
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -64,11 +64,13 @@ Runtime behavior:
|
|||||||
- rejection of localhost, metadata, private, reserved, multicast, and link-local targets;
|
- rejection of localhost, metadata, private, reserved, multicast, and link-local targets;
|
||||||
- redirect revalidation on each hop;
|
- redirect revalidation on each hop;
|
||||||
- one-time public DNS resolution per hop plus an `httpcore`/`httpx` pinned
|
- one-time public DNS resolution per hop plus an `httpcore`/`httpx` pinned
|
||||||
transport that connects to the validated public IP while preserving the original URL, Host header, and TLS SNI, closing DNS-rebinding time-of-check drift;
|
transport that connects to the validated public IP while preserving the
|
||||||
|
original URL, Host header, and TLS SNI, closing DNS-rebinding time-of-check
|
||||||
|
drift;
|
||||||
- metadata, Open Graph image, list, table, code block, PDF, and text extraction;
|
- metadata, Open Graph image, list, table, code block, PDF, and text extraction;
|
||||||
- readable text extraction for `text/*`, Markdown, `.txt`, `.json`, `.jsonl`, and JSON content types;
|
- readable text extraction for `text/*`, Markdown, `.txt`, `.json`, `.jsonl`, and JSON content types;
|
||||||
- central User-Agent behavior through `WEB_FETCH_USER_AGENT`;
|
- central User-Agent behavior through `WEB_FETCH_USER_AGENT`;
|
||||||
- soft and hard download byte caps through `WEB_FETCH_SOFT_MAX_BYTES` and `WEB_FETCH_HARD_MAX_BYTES`, with declared-length and streaming-budget checks; decoded response bytes are capped even for compressed transfer encodings, and truncation is reported;
|
- soft and hard download byte caps through `WEB_FETCH_SOFT_MAX_BYTES` and `WEB_FETCH_HARD_MAX_BYTES`, with declared-length and streaming-budget checks; requests prefer identity transfer encoding so compressed bodies cannot bypass the effective body cap;
|
||||||
- JS-heavy empty result hints;
|
- JS-heavy empty result hints;
|
||||||
- cache writes;
|
- cache writes;
|
||||||
- empty/error result shape, including explicit HTTP-status failures instead of raising through callers.
|
- empty/error result shape, including explicit HTTP-status failures instead of raising through callers.
|
||||||
@@ -122,28 +124,12 @@ Deep research wraps fetched webpage content through `untrusted_context_message("
|
|||||||
|
|
||||||
## Optional And Platform Behavior
|
## Optional And Platform Behavior
|
||||||
|
|
||||||
`ddgs` is optional; provider code has an HTML fallback. Search cache and analytics state live under the shared data dir and mkdir failures in read-only image layers are tolerated where possible. PDF extraction prefers pypdf layout text with a plain-text compatibility fallback and page labels, then optional pdfminer; a truncated soft-budget PDF can retry within the hard cap when its declared size allows. Native SearXNG defaults to `http://localhost:8080`; Docker uses the compose `searxng` service URL and pins the SearXNG image with a healthcheck.
|
`ddgs` is optional; provider code has an HTML fallback. Search cache and analytics state live under the shared data dir and mkdir failures in read-only image layers are tolerated where possible. PDF extraction uses `pdfminer.six` only when installed. Native SearXNG defaults to `http://localhost:8080`; Docker uses the compose `searxng` service URL and pins the SearXNG image with a healthcheck.
|
||||||
|
|
||||||
Compose preserves retained SearXNG settings but runs `scripts/migrate_searxng_settings.py` before startup to add missing `use_default_settings: true` inheritance. The migration accepts only a regular single-document YAML mapping, preserves BOM/newline/style/ownership/mode, writes and directory-fsyncs atomically, and no-ops when the key exists. Compose treats migration failure as non-fatal so SearXNG health reports the retained-file problem instead of the wrapper command preventing startup.
|
Compose preserves retained SearXNG settings but runs `scripts/migrate_searxng_settings.py` before startup to add missing `use_default_settings: true` inheritance. The migration accepts only a regular single-document YAML mapping, preserves BOM/newline/style/ownership/mode, writes and directory-fsyncs atomically, and no-ops when the key exists. Compose treats migration failure as non-fatal so SearXNG health reports the retained-file problem instead of the wrapper command preventing startup.
|
||||||
|
|
||||||
`httpx` and BeautifulSoup are required runtime dependencies for the active search/fetch path.
|
`httpx` and BeautifulSoup are required runtime dependencies for the active search/fetch path.
|
||||||
|
|
||||||
## Query Repair And Extractive Observations
|
|
||||||
|
|
||||||
`services/search/core.py` normalizes conversational provider queries, filters low-relevance results, respects explicit site scope, retries bounded broadened queries after empty results, and reapplies relevance/ranking on cache hits. Completed empty provider responses advance through the chain instead of repeating the same request as if it were a transport error. Named scholarly work lookup can use arXiv/OpenAlex metadata before generic search, and exact arXiv identifiers have a direct source path.
|
|
||||||
|
|
||||||
`src/search_intent.py` distinguishes requested publication windows from current reference information: current documentation/configuration alone does not imply recently published pages. `src/search_passages.py` owns literal, query-relevant excerpts and balanced bounded search observations across fetched sources, preserving explicit omission markers and source headers rather than prefix-truncating away later sources. These are extractive observations, not generated source claims. Deep research shares provider primitives through its navigator but retains its own result and failure shapes.
|
|
||||||
|
|
||||||
## Retrieval Limits And Source Selection
|
|
||||||
|
|
||||||
When SearXNG is primary and `searxng_yep` is absent from the configured chain, `_build_provider_chain()` inserts that separate candidate at index one, including when SearXNG is the only configured provider. An already configured YEP position is retained; other primary providers do not get the automatic insertion. Missing keyed providers are excluded. Exact-empty query relaxation is bounded and disabled for explicit operators, quotes or exclusions. Low-relevance filtering can retain scoped results rather than invent a confident match. Temporal qualifiers can exclude a year from product-number overlap checks, while genuine numeric model names remain significant.
|
|
||||||
|
|
||||||
News category and publication window are independent: current events infer a day and news a week where no explicit window exists, while current reference documentation does not imply recent publication. Software release hints can retain general category; explicit windows survive JSON-to-HTML and news-to-general fallback along with language/engines/SafeSearch. The optional DuckDuckGo HTML request has a maximum five-second request timeout, not a bound on the complete multi-provider search.
|
|
||||||
|
|
||||||
HTML extraction removes chrome from a parsed copy, prefers semantic main content and a sole substantial article (at least 200 stripped characters), and avoids ancestor duplication; multiple article cards retain main context. Thin-body and access-challenge rules are heuristics. An exact challenge title with corroborating short body is an unavailable page even under HTTP200 and is not cached as article evidence. XML/Atom preserves bounded raw text; failed arXiv category/date listings can return a suggested start-zero/max100 Atom URL without fetching it or claiming completeness. Extractor caches use the current semantic-links namespace rather than old parser results.
|
|
||||||
|
|
||||||
Scholarly metadata uses a versioned User-Agent, a 12-second lookup timeout and shared 20-second monotonic budget, with at most three manually checked redirects and cleared original query parameters after redirect. This helper checks destinations but uses ordinary HTTP transport rather than claiming the separate outbound-fetch DNS connection pinning contract. Title/identifier matching remains heuristic metadata lookup, not independent verification of a paper. Direct browser-source citations and search snippets remain untrusted and insufficient to certify full-report evidence.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Search route handlers need direct tests for request body formats, provider validation, provider availability, and route error/empty-result shapes.
|
- Search route handlers need direct tests for request body formats, provider validation, provider availability, and route error/empty-result shapes.
|
||||||
|
|||||||
+12
-31
@@ -1,6 +1,6 @@
|
|||||||
# Settings And Admin Surfaces
|
# Settings And Admin Surfaces
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ This spec covers settings and admin surfaces in:
|
|||||||
- `routes/model_routes.py` for `/api/tools` and settings-bound model endpoint references;
|
- `routes/model_routes.py` for `/api/tools` and settings-bound model endpoint references;
|
||||||
- `src/agent_tools/admin_tools.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_index.py` for `manage_settings`;
|
- `src/agent_tools/admin_tools.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_index.py` for `manage_settings`;
|
||||||
- `src/agent_loop.py` for stale agent prompt references to settings APIs;
|
- `src/agent_loop.py` for stale agent prompt references to settings APIs;
|
||||||
- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn,agent,api,imageModels,speech,writingStyle}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`;
|
- frontend modules `static/js/appConfig.js`, `static/js/settings.js`, `static/js/settings/{registry,navigation,lifecycle,search,dom,sidebar,shell,peek,oauthReturn}.js`, `static/js/admin.js`, `static/js/presets.js`, `static/js/theme.js`, and `static/js/storage.js`;
|
||||||
- CLI helpers `scripts/odysseus-preset` and `scripts/odysseus-theme`.
|
- CLI helpers `scripts/odysseus-preset` and `scripts/odysseus-theme`.
|
||||||
|
|
||||||
Generic API integrations are cross-referenced in `integrations.md`. Model endpoint CRUD and endpoint cleanup are covered in `llm-models.md`. Email/contact/calendar legacy setting fallbacks stay with their domain specs.
|
Generic API integrations are cross-referenced in `integrations.md`. Model endpoint CRUD and endpoint cleanup are covered in `llm-models.md`. Email/contact/calendar legacy setting fallbacks stay with their domain specs.
|
||||||
@@ -54,10 +54,10 @@ Other active stores include:
|
|||||||
Settings runtime:
|
Settings runtime:
|
||||||
|
|
||||||
- `GET /api/auth/features` is public feature visibility metadata;
|
- `GET /api/auth/features` is public feature visibility metadata;
|
||||||
- `POST /api/auth/features` requires admin unless auth is explicitly disabled;
|
- `POST /api/auth/features` is admin-only;
|
||||||
- `GET /api/auth/settings` returns full settings to admins and the disabled-auth operator;
|
- `GET /api/auth/settings` returns full settings to admins;
|
||||||
- with auth enabled, non-admin or unauthenticated `GET /api/auth/settings` returns `scrub_settings()` output;
|
- non-admin or unauthenticated `GET /api/auth/settings` returns `scrub_settings()` output;
|
||||||
- `POST /api/auth/settings` requires admin unless auth is explicitly disabled and only writes keys present in `DEFAULT_SETTINGS`.
|
- `POST /api/auth/settings` is admin-only and only writes keys present in `DEFAULT_SETTINGS`.
|
||||||
|
|
||||||
`src.settings_scrub` owns deep secret-key scrubbing for non-admin settings reads, including snake_case and camelCase secret-like key names. It preserves structure while blanking secret-shaped string values.
|
`src.settings_scrub` owns deep secret-key scrubbing for non-admin settings reads, including snake_case and camelCase secret-like key names. It preserves structure while blanking secret-shaped string values.
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ Admin gates inherit the auth contracts in `auth-security.md`: normal deployments
|
|||||||
- bundled accessibility font selection such as OpenDyslexic and text-size variable application;
|
- bundled accessibility font selection such as OpenDyslexic and text-size variable application;
|
||||||
- CSS variable application.
|
- CSS variable application.
|
||||||
|
|
||||||
`static/js/settings.js` coordinates compatibility exports and delegates Agent, image-model, speech and writing-style panel initialization to their named leaf modules; while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state.
|
`static/js/settings.js` owns domain panel load/save behavior and compatibility exports, while `static/js/settings/registry.js` is the canonical group/panel metadata inventory. `navigation.js` activates panels and lazy admin content, `search.js` implements the registry-backed finder while filtering admin-only entries, `lifecycle.js` owns modal open/close/Escape/drag/docking behavior, `sidebar.js` owns persisted collapse/resize state, and `dom.js` holds shared DOM helpers. `shell.js` owns the coordination above those primitives — per-panel activation side effects, the admin-controller handoff, `.admin-only` visibility, and the public `open()`/`close()` entry points — holding no panel state of its own and taking what it needs from `settings.js` as injected callbacks. `peek.js` owns the Appearance window-fade chrome and clearing it when the user leaves that panel, and `oauthReturn.js` owns the once-per-load return path from the Google OAuth redirect into the Integrations panel. Registry/DOM consistency is a tested contract; new panels must update both the registry metadata and actual DOM. `static/js/appConfig.js` shares one promise cache for settings and tool reads across frontend modules, consumes a login-page settings prefetch once, drops rejected promises for retry, and requires settings/tool writers to invalidate the matching cache; `/api/tools` writes invalidate both entries because disabled tools live in settings state.
|
||||||
|
|
||||||
Settings panels cover provider/model/search/research/reminder/email/CalDAV/CardDAV/vault, accessibility/font/text-size, scoped tokens, and unified integrations. The hidden legacy fallback editor was removed; no current Settings panel exposes the new foreground fallback keys, so opt-in exists only through owner-scoped preferences/internal callers until a deliberate UI is added. Email OAuth connect preserves the selected SMTP security mode and returns to the Settings surface after callback. `static/js/admin.js` owns user/admin panels, model endpoints, builtin tool toggles, MCP forms, feature toggles, token/webhook panels, diagnostics, backup/import, and danger-zone wipes.
|
Settings panels cover provider/model/search/research/reminder/email/CalDAV/CardDAV/vault, accessibility/font/text-size, scoped tokens, and unified integrations. The hidden legacy fallback editor was removed; no current Settings panel exposes the new foreground fallback keys, so opt-in exists only through owner-scoped preferences/internal callers until a deliberate UI is added. Email OAuth connect preserves the selected SMTP security mode and returns to the Settings surface after callback. `static/js/admin.js` owns user/admin panels, model endpoints, builtin tool toggles, MCP forms, feature toggles, token/webhook panels, diagnostics, backup/import, and danger-zone wipes.
|
||||||
|
|
||||||
@@ -95,14 +95,6 @@ Runtime behavior:
|
|||||||
|
|
||||||
`scripts/odysseus-preset` is a local CLI for preset store maintenance and backup of `presets.json`.
|
`scripts/odysseus-preset` is a local CLI for preset store maintenance and backup of `presets.json`.
|
||||||
|
|
||||||
Custom presets now carry optional persona continuity notes, a `general`/`health` memory schema, `thinking_mode` (`on`, `off`, or provider default), and `show_persona_name`. `PresetManager.update_persona_memory()` updates matching active and saved personas; these remain shared preset data. The default custom output limit is 32,768 tokens.
|
|
||||||
|
|
||||||
## Writing Style And Admin Controls
|
|
||||||
|
|
||||||
`document_writing_style` and `email_writing_style` are separate global settings. `POST /api/auth/settings/document-style/extract` requires an admin unless auth is explicitly disabled. It reads a bounded document upload, extracts prose, and calls the owner-resolved Utility model (Default Chat if unavailable) with at most 30,000 sample characters. Its prompt treats the sample as untrusted data; the response is a proposed style string, and the temporary file is removed in `finally`. `static/js/settings/writingStyle.js` owns the UI. This is a model inference path rather than a deterministic style parser.
|
|
||||||
|
|
||||||
`auto_compact_threshold_percent` defaults to 85 and settings writes clamp it to 50–95. `utility_reasoning_effort` is an allowed per-user override; utility fallback entries can specify their own effort. Admin `PUT /api/auth/users/{username}/password` delegates to `AuthManager.reset_user_password`, accepts only existing non-admin targets, revokes their sessions after reset, and validates minimum length and the 72-byte UTF-8 limit.
|
|
||||||
|
|
||||||
## Tools Settings
|
## Tools Settings
|
||||||
|
|
||||||
`routes.model_routes` owns `/api/tools`, which writes `settings.json:disabled_tools` for global builtin tool toggles.
|
`routes.model_routes` owns `/api/tools`, which writes `settings.json:disabled_tools` for global builtin tool toggles.
|
||||||
@@ -158,8 +150,8 @@ Vault tool paths duplicate some route behavior and can return vault item secrets
|
|||||||
|
|
||||||
## Security And Provenance
|
## Security And Provenance
|
||||||
|
|
||||||
- With auth enabled, non-admin and unauthenticated settings reads are scrubbed; explicit disabled-auth operator reads return full settings.
|
- Non-admin and unauthenticated settings reads are scrubbed.
|
||||||
- Full settings responses, admin edit forms, vault flows, backup files, and local CLI artifacts can contain secrets and need their applicable access and storage protections.
|
- Admin settings reads, admin edit forms, vault flows, backup files, and local CLI artifacts can contain secrets and must remain admin-only or locally protected.
|
||||||
- Backup artifacts are sensitive because settings may include API keys, passwords, tokens, and endpoint credentials.
|
- Backup artifacts are sensitive because settings may include API keys, passwords, tokens, and endpoint credentials.
|
||||||
- Diagnostics and logs should avoid adding secret-bearing values.
|
- Diagnostics and logs should avoid adding secret-bearing values.
|
||||||
- Admin wipe is global per kind and crosses owners.
|
- Admin wipe is global per kind and crosses owners.
|
||||||
@@ -179,25 +171,14 @@ Vault tool paths duplicate some route behavior and can return vault item secrets
|
|||||||
- Theme/preset frontend helpers tolerate malformed localStorage values.
|
- Theme/preset frontend helpers tolerate malformed localStorage values.
|
||||||
- CLI helpers are local maintenance surfaces and may bypass HTTP route policy.
|
- CLI helpers are local maintenance surfaces and may bypass HTTP route policy.
|
||||||
|
|
||||||
## App Configuration Gates And Cleanup
|
|
||||||
|
|
||||||
`routes/auth_routes.py` uses `_require_admin_user()` for settings writes, feature writes, document-style extraction and integration management. Explicit `AUTH_ENABLED=false` admits the operator without a session identity, and settings GET returns unscrubbed values in that mode; auth-enabled callers need an admin for writes and get scrubbed reads unless admin. User-store administration still requires a real admin identity. `static/js/settings.js` reports shortcut save failure with a toast; settings writes use the existing response-status-checking API helper.
|
|
||||||
|
|
||||||
`src/cleanup_service.py` previews and deletes using durable `ChatMessage` row counts rather than `Session.message_count`; it selects only the configured most-recent IDs for protection. The DELETE rechecks owner, archive state, age, importance and actual message threshold. Supported databases use DELETE RETURNING to attribute deleted rows and estimated space; legacy SQLite has a rowcount/survivor fallback with bounded estimate attribution. Cache eviction follows commit and includes observed externally removed candidates; estimates are not a measurement of reclaimed database bytes.
|
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|
||||||
Current targeted coverage includes settings store fallback/error paths, settings scrub, shared frontend config caching/invalidation/prefetch behavior, prefs no-clobber behavior, atomic preset store/migration/CLI/localStorage helpers, backup import cross-user dedup, backup CLI restore/list-race safety, cleanup owner scope, diagnostics admin-gate/source/service-health/log-tail checks, admin promote/demote, admin wipe gallery, font family derivation, theme helper behavior, vault password-not-in-argv checks, setup/auth regressions, reserved usernames, Google email OAuth route/helper behavior, and a token-budget `manage_settings` path.
|
Current targeted coverage includes settings store fallback/error paths, settings scrub, shared frontend config caching/invalidation/prefetch behavior, prefs no-clobber behavior, atomic preset store/migration/CLI/localStorage helpers, backup import cross-user dedup, backup CLI restore/list-race safety, cleanup owner scope, diagnostics admin-gate/source/service-health/log-tail checks, admin promote/demote, admin wipe gallery, font family derivation, theme helper behavior, vault password-not-in-argv checks, setup/auth regressions, reserved usernames, Google email OAuth route/helper behavior, and a token-budget `manage_settings` path.
|
||||||
|
|
||||||
## Panel Save And Vault Identity Details
|
|
||||||
|
|
||||||
`static/js/settings/api.js` centralizes settings POSTs, rejects non-OK/403 responses and invalidates the shared cache in finally even on failure. Older per-panel unchecked-response behavior is superseded by this final helper; individual UI completion is not model/provider acceptance. Image model selectors retain the final mixed eligibility filter rather than an image-only historical rule. Sidebar build information fetches public version/build/source-commit metadata, hides failed/unknown responses and abbreviates hexadecimal commits for display; arbitrary overrides are not byte attestation.
|
|
||||||
|
|
||||||
Vault tool bindings require an explicit matching configuration owner and pin backend/config/session incarnations plus exact observed item identities. `routes/vault/vault_routes.py` checks configuration ownership on save and clears session/unlock time when adopting ownerless legacy state. Same-owner configuration edits retain saved session/unlock fields while invalidating bindings whose configuration incarnation changes. Configuration reads and login/unlock/lock/logout use common `require_admin()` without that owner-binding check. Observed catalog names or selectors cannot renew a revoked tool backend; search observations can update selected entries without proving every remote account/session is fresh.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Settings/features route tests cover disabled-auth operation, auth-enabled read scrubbing, admin/non-admin writes, numeric validation and ignoring unknown keys; real auth-middleware integration and broader store-failure combinations still need coverage.
|
- Add route tests for `/api/auth/settings`: anonymous/non-admin scrubbed reads, admin full reads, non-admin POST rejection, and unknown-key ignore behavior.
|
||||||
|
- Add route tests for `/api/auth/features` admin writes.
|
||||||
- Add `/api/tools` and `manage_settings` tests for secret write refusal, enum/integer coercion failures, structured-setting refusal, reset/default behavior, endpoint/model resolution, and tool enable/disable aliases.
|
- Add `/api/tools` and `manage_settings` tests for secret write refusal, enum/integer coercion failures, structured-setting refusal, reset/default behavior, endpoint/model resolution, and tool enable/disable aliases.
|
||||||
- Add backup tests for secret-bearing export policy, owner-scoped exported sections, invalid import handling, skills dedup, settings/features merge, and admin gates.
|
- Add backup tests for secret-bearing export policy, owner-scoped exported sections, invalid import handling, skills dedup, settings/features merge, and admin gates.
|
||||||
- Add diagnostics tests for broader error redaction and sensitive output limits.
|
- Add diagnostics tests for broader error redaction and sensitive output limits.
|
||||||
@@ -206,4 +187,4 @@ Vault tool bindings require an explicit matching configuration owner and pin bac
|
|||||||
- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests, and the real-ESM coordinator smoke in `tests/helpers/test_settings_shell_coordinator.mjs` additionally covers the shell's admin-only visibility, admin-controller handoff and Peek fade/clear behavior.
|
- Add broader frontend behavior coverage for Settings/Admin panel save/load flows, vault password clearing, diagnostics buttons, cleanup/wipe confirmations, custom font/theme wiring, and tab state; registry/navigation/finder/lifecycle contracts now have focused source/JS tests, and the real-ESM coordinator smoke in `tests/helpers/test_settings_shell_coordinator.mjs` additionally covers the shell's admin-only visibility, admin-controller handoff and Peek fade/clear behavior.
|
||||||
- Decide whether `user_templates` and `group_presets` should remain shared despite user-facing names.
|
- Decide whether `user_templates` and `group_presets` should remain shared despite user-facing names.
|
||||||
- Decide whether backup/import should preserve explicit owner fields or force imported owner ownership.
|
- Decide whether backup/import should preserve explicit owner fields or force imported owner ownership.
|
||||||
- Settings panel code now has additional domain modules, but `static/js/settings.js` and `static/js/admin.js` remain substantial coordinators; future extraction must preserve registry and lifecycle ownership.
|
- Continue decomposing the still-large `static/js/settings.js` (5,583 lines) and `static/js/admin.js` (4,122 lines) without duplicating registry ownership. The shell and navigation concerns now live in `static/js/settings/`; what remains in `settings.js` is panel code, and `initUnifiedIntegrations()` alone is ~2,140 lines of it. `admin.js` has no extracted shell of its own yet.
|
||||||
|
|||||||
+31
-55
@@ -1,6 +1,6 @@
|
|||||||
# Shell And MCP
|
# Shell And MCP
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@2e2bb52 | 2026-08-16
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -9,8 +9,6 @@ This spec covers shell and MCP behavior in:
|
|||||||
- shell routes in `routes/shell_routes.py`;
|
- shell routes in `routes/shell_routes.py`;
|
||||||
- the standalone shell helper in `services/shell/service.py`;
|
- the standalone shell helper in `services/shell/service.py`;
|
||||||
- agent shell/background execution in `src/tool_execution.py`, `src/agent_tools/subprocess_tools.py`, `src/bg_jobs.py`, and `src/bg_monitor.py`;
|
- agent shell/background execution in `src/tool_execution.py`, `src/agent_tools/subprocess_tools.py`, `src/bg_jobs.py`, and `src/bg_monitor.py`;
|
||||||
- `src/containment.py`, `src/containment_worker.py`, `src/process_ownership.py`, `src/process_lifecycle.py`, and `src/process_reaper.py`;
|
|
||||||
- `src/browser_identity.py`, `src/browser_lifecycle.py`, `src/browser_observation.py`, and `src/agent_runtime/process_resources.py`;
|
|
||||||
- app wiring and startup/shutdown in `app.py`;
|
- app wiring and startup/shutdown in `app.py`;
|
||||||
- MCP configuration routes in canonical `routes/mcp/mcp_routes.py`, with `routes/mcp_routes.py` as a compatibility shim;
|
- MCP configuration routes in canonical `routes/mcp/mcp_routes.py`, with `routes/mcp_routes.py` as a compatibility shim;
|
||||||
- MCP runtime state in `src/mcp_manager.py`;
|
- MCP runtime state in `src/mcp_manager.py`;
|
||||||
@@ -51,11 +49,11 @@ Those endpoints probe local or SSH-remote packages, prepend user install bins fo
|
|||||||
|
|
||||||
## Agent Shell And Background Jobs
|
## Agent Shell And Background Jobs
|
||||||
|
|
||||||
`src.tool_execution` owns policy/dispatch for agent-side `bash` execution and the `#!bg` marker; `src.agent_tools.subprocess_tools` owns native shell/Python/host-bridge handlers. A `bash` block whose first line is `#!bg` starts a detached background job instead of holding the chat stream open. Native enforcing execution is unavailable on unsupported platforms, including Windows; compatibility/admin shell selection is a separate path and does not establish native containment.
|
`src.tool_execution` owns agent-side `bash` execution and the `#!bg` marker. A `bash` block whose first line is `#!bg` starts a detached background job instead of holding the chat stream open. On Windows, request-scoped workspace shell execution prefers Git Bash when available so POSIX-style agent commands and path confinement use the intended shell instead of `cmd.exe` parsing.
|
||||||
|
|
||||||
`src.bg_jobs` owns disk-backed job state under `data/bg_jobs.json` and `data/bg_jobs/*`. It stores wrapper scripts, logs, exit-code files, timestamps, status, and capped result text.
|
`src.bg_jobs` owns disk-backed job state under `data/bg_jobs.json` and `data/bg_jobs/*`. It stores wrapper scripts, logs, exit-code files, timestamps, status, and capped result text.
|
||||||
|
|
||||||
`src.bg_monitor` owns polling and auto-continuation. A completed job follows up only after exact owner/request/thread/generation linkage is checked, including revalidation after draining the continuation stream. Busy sessions retry; missing/terminal authority does not renew itself. Only after assistant persistence is follow-up marked delivered; suppression of an unfollowable result is distinct from delivery.
|
`src.bg_monitor` owns polling and auto-continuation. When a job finishes, it injects the job result into the session, drains the agent stream, persists only the assistant continuation plus `bg_result` metadata, and marks the job followed up.
|
||||||
|
|
||||||
Runtime behavior:
|
Runtime behavior:
|
||||||
|
|
||||||
@@ -77,7 +75,10 @@ Runtime behavior:
|
|||||||
|
|
||||||
`src.mcp_manager.McpManager` owns live connection state, stdio/SSE/Streamable HTTP transports, sessions, tool schemas, qualified names, and tool calls. HTTP route operations update both database state and live manager state where applicable. Streamable HTTP connects in a background task, can report `connecting` or `needs_auth`, and surfaces an authorization URL when the OAuth client flow redirects. Enabled configured servers connect concurrently at startup; each server has its own 20-second connection timeout and records `timeout` state without delaying siblings. The startup task has no second outer timeout.
|
`src.mcp_manager.McpManager` owns live connection state, stdio/SSE/Streamable HTTP transports, sessions, tool schemas, qualified names, and tool calls. HTTP route operations update both database state and live manager state where applicable. Streamable HTTP connects in a background task, can report `connecting` or `needs_auth`, and surfaces an authorization URL when the OAuth client flow redirects. Enabled configured servers connect concurrently at startup; each server has its own 20-second connection timeout and records `timeout` state without delaying siblings. The startup task has no second outer timeout.
|
||||||
|
|
||||||
Stdio and SSE connection setup registers the session, exit stack, tool list, and status as one completed unit. If initialization or tool discovery fails before registration, the partial `AsyncExitStack` is closed so transports do not leak into later reconnect attempts.
|
Stdio and SSE connection setup registers the session, exit stack, tool list,
|
||||||
|
and status as one completed unit. If initialization or tool discovery fails
|
||||||
|
before registration, the partial `AsyncExitStack` is closed so transports do
|
||||||
|
not leak into later reconnect attempts.
|
||||||
|
|
||||||
`src.agent_tools.admin_tools.do_manage_mcp()` is the agent/admin tool path for MCP config and is re-exported lazily through `src.tool_implementations` for compatibility. It is narrower than the HTTP routes: add is stdio-only, command values are checked against an allowlist/denylist before persistence, and enable/disable primarily flips DB config. `scripts/odysseus-mcp` is config-only; it reads and mutates database rows, redacts env values by default, and does not report live manager connection state.
|
`src.agent_tools.admin_tools.do_manage_mcp()` is the agent/admin tool path for MCP config and is re-exported lazily through `src.tool_implementations` for compatibility. It is narrower than the HTTP routes: add is stdio-only, command values are checked against an allowlist/denylist before persistence, and enable/disable primarily flips DB config. `scripts/odysseus-mcp` is config-only; it reads and mutates database rows, redacts env values by default, and does not report live manager connection state.
|
||||||
|
|
||||||
@@ -92,11 +93,20 @@ Python stdio built-ins:
|
|||||||
- RAG;
|
- RAG;
|
||||||
- email.
|
- email.
|
||||||
|
|
||||||
The optional browser built-in uses `npx -y @playwright/mcp@latest --headless --caps vision`. It is cache-gated by checking npm's `_npx` cache for the requested package and falling back to `npx --no-install`; uncached/missing browser MCP is logged with install guidance and skipped rather than blocking startup or downloading packages at boot. Image, memory and RAG Python built-ins normally defer to native/code-block descriptions; email remains a qualified MCP schema/prompt exception. Connected browser schemas do not authorize page execution.
|
The optional browser built-in uses `npx -y @playwright/mcp@latest --headless --caps vision`. It is cache-gated by checking npm's `_npx` cache for the requested package and falling back to `npx --no-install`; uncached/missing browser MCP is logged with install guidance and skipped rather than blocking startup or downloading packages at boot. Python built-ins are omitted from OpenAI function schemas because native/code-block paths already describe those capabilities; the browser built-in is exposed through MCP function schemas when connected.
|
||||||
|
|
||||||
Built-in Python servers prepend the app root to inherited `PYTHONPATH` rather than replacing the environment, so container/dev site-packages remain visible on initial connect and automatic reconnect. `McpManager.call_tool()` can reconnect a built-in once after an exception only when the call has no bound backend identity. Bound calls return the failure so a fresh connection cannot silently replace the sealed backend incarnation. User-configured MCP servers return the call failure instead of automatic reconnect. Browser calls have a bounded timeout; timed-out calls are abandoned without retry because page state is unknown.
|
Built-in Python servers prepend the app root to inherited `PYTHONPATH` rather
|
||||||
|
than replacing the environment, so container/dev site-packages remain visible
|
||||||
|
on initial connect and automatic reconnect. They can be reconnected once on
|
||||||
|
tool-call failure. User-configured MCP servers return the call failure instead
|
||||||
|
of automatic reconnect.
|
||||||
|
|
||||||
The built-in email MCP server is owner-aware when an owner is supplied by the caller or configured through `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`; if owner-scoped email accounts exist and no owner is available, email MCP fails closed instead of exposing global accounts. Other built-in servers remain process-global/admin trust-boundary tools unless their own subsystem spec says otherwise.
|
The built-in email MCP server is owner-aware when an owner is supplied by the
|
||||||
|
caller or configured through `ODYSSEUS_MCP_EMAIL_OWNER` /
|
||||||
|
`ODYSSEUS_EMAIL_OWNER`; if owner-scoped email accounts exist and no owner is
|
||||||
|
available, email MCP fails closed instead of exposing global accounts. Other
|
||||||
|
built-in servers remain process-global/admin trust-boundary tools unless their
|
||||||
|
own subsystem spec says otherwise.
|
||||||
|
|
||||||
## Agent MCP Exposure
|
## Agent MCP Exposure
|
||||||
|
|
||||||
@@ -118,7 +128,9 @@ After model-visible external/workspace context, arbitrary MCP actions classify f
|
|||||||
## Degraded And Platform Behavior
|
## Degraded And Platform Behavior
|
||||||
|
|
||||||
- `app.py` starts the background monitor and MCP startup tasks asynchronously; MCP startup is non-critical to app readiness.
|
- `app.py` starts the background monitor and MCP startup tasks asynchronously; MCP startup is non-critical to app readiness.
|
||||||
- Configured MCP servers start concurrently with a per-server 20-second bound; timeout state is stored per server and partial connection resources are closed before returning.
|
- Configured MCP servers start concurrently with a per-server 20-second bound;
|
||||||
|
timeout state is stored per server and partial connection resources are
|
||||||
|
closed before returning.
|
||||||
- Missing Python `mcp` dependency degrades attempted MCP connections to error status.
|
- Missing Python `mcp` dependency degrades attempted MCP connections to error status.
|
||||||
- Missing or uncached browser NPX package is optional and log-only during built-in startup; startup should not perform an implicit package download.
|
- Missing or uncached browser NPX package is optional and log-only during built-in startup; startup should not perform an implicit package download.
|
||||||
- Windows does not support POSIX PTY/tmux paths; streaming falls back to pipes or detached logfile behavior.
|
- Windows does not support POSIX PTY/tmux paths; streaming falls back to pipes or detached logfile behavior.
|
||||||
@@ -129,13 +141,16 @@ After model-visible external/workspace context, arbitrary MCP actions classify f
|
|||||||
## Security And Provenance
|
## Security And Provenance
|
||||||
|
|
||||||
- Admin shell is intentional host command execution; do not expose shell routes or shell tools to regular users.
|
- Admin shell is intentional host command execution; do not expose shell routes or shell tools to regular users.
|
||||||
- `routes.shell_routes._require_admin()` gates shell and selected Cookbook native controls, rejecting internal-tool identity/header administration. Auth-disabled operator access requires direct loopback without forwarding or disallowed browser-origin metadata; an Authorization header alone is not excluded by this transport helper. Auth-enabled administration requires a present auth manager and human admin. MCP configuration instead uses common `core.middleware.require_admin()`, which admits a matching internal token/sentinel or explicit auth-disabled mode; it does not inherit the shell gate's transport/resource rules.
|
- `_require_admin()` gates shell routes and MCP config routes. The internal-tool loopback can be admin-equivalent only after auth middleware validates the internal token and loopback client.
|
||||||
- `_reject_cross_site()` currently applies to `/api/cookbook/packages`; `/api/shell/exec`, `/api/shell/stream`, package install, rebuild, and MCP write/OAuth routes do not call it directly.
|
- `_reject_cross_site()` currently applies to `/api/cookbook/packages`; `/api/shell/exec`, `/api/shell/stream`, package install, rebuild, and MCP write/OAuth routes do not call it directly.
|
||||||
- Shell helper paths use argv-based SSH, reject option-like hosts, validate SSH ports through shared helpers, restrict remote venv characters, and allowlist package installs.
|
- Shell helper paths use argv-based SSH, reject option-like hosts, validate SSH ports through shared helpers, restrict remote venv characters, and allowlist package installs.
|
||||||
- Non-admin/public tool policy blocks `bash`, `python`, file tools, `manage_mcp`, and all `mcp__*` tools.
|
- Non-admin/public tool policy blocks `bash`, `python`, file tools, `manage_mcp`, and all `mcp__*` tools.
|
||||||
- MCP stdio server registration is arbitrary host process execution and is admin-only.
|
- MCP stdio server registration is arbitrary host process execution and is admin-only.
|
||||||
- MCP OAuth key/token file paths supplied through routes are confined under `data/mcp_oauth`; generic Streamable HTTP OAuth token state is encrypted in the database.
|
- MCP OAuth key/token file paths supplied through routes are confined under `data/mcp_oauth`; generic Streamable HTTP OAuth token state is encrypted in the database.
|
||||||
- Built-in MCP servers are local/admin trust-boundary tools and are not automatically equivalent to owner-scoped HTTP route behavior. Email MCP is the current exception with explicit owner filtering; other built-ins need their own owner policy before being treated as scoped surfaces.
|
- Built-in MCP servers are local/admin trust-boundary tools and are not
|
||||||
|
automatically equivalent to owner-scoped HTTP route behavior. Email MCP is
|
||||||
|
the current exception with explicit owner filtering; other built-ins need
|
||||||
|
their own owner policy before being treated as scoped surfaces.
|
||||||
- MCP output is external-untrusted tool output and arms the high-impact continuation gate when model-visible. Current MCP text output is still not centrally capped before model re-entry.
|
- MCP output is external-untrusted tool output and arms the high-impact continuation gate when model-visible. Current MCP text output is still not centrally capped before model re-entry.
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
@@ -144,54 +159,15 @@ Current targeted coverage includes Windows PTY import degradation, PTY unsupport
|
|||||||
|
|
||||||
The shell/MCP audit ran the targeted venv subset with 78 passing tests and one warning.
|
The shell/MCP audit ran the targeted venv subset with 78 passing tests and one warning.
|
||||||
|
|
||||||
## Contained Execution And Native Browser Identity
|
|
||||||
|
|
||||||
Model-reachable native shell/Python execution uses `src.containment.agent_spec()` with enforcing filesystem, process-tree, and wall-clock requirements. Missing required mechanisms return containment-unavailable results before code runs; optional network/memory/process-count dimensions are recorded separately and must not be assumed enforced. Bubblewrap provides the Linux filesystem/namespace path when available. Direct admin HTTP shell routes remain a distinct operator surface. Host/client bridges declare external execution rather than claiming local sandbox enforcement.
|
|
||||||
|
|
||||||
Process identity is PID plus start token, not PID alone. `src.process_ownership` probes identities; `src.process_lifecycle` protects the server process group and reports death only after observation. Durable callers require positive PID/start-token identity; the generic tree helper also retains a compatibility mode without that requirement, so this is not a universal signaling guarantee. Background jobs persist launch generation, containment, and process identity so stop/restart paths can refuse stale targets.
|
|
||||||
|
|
||||||
`private_browser` currently admits only `session_info`. All page navigation, snapshots, screenshots, close/batch/recovery actions and raw global Playwright browser operations are denied unconditionally at the common boundary before binding or dispatch. `src.browser_identity` and lifecycle/observation modules retain owner/thread/session/incarnation machinery and metadata-only prompt support, but these do not enable page execution. The pinned agent-browser 0.35 Linux x64/arm64 discovery, clean environment and read-only bounded CDP observations do not amount to usable browser acceptance; metadata lookup does not adopt or create an unrelated launch.
|
|
||||||
|
|
||||||
`McpManager` additionally registers backend incarnations and exposes server-owned resource observations for admitted calls. Built-in connection owner tasks keep AnyIO transport scopes alive until disconnect; changed connections invalidate sealed backend identities. Existing transport configuration and schema discovery remain manager-owned.
|
|
||||||
|
|
||||||
## Launch, Cleanup And Bridge Boundaries
|
|
||||||
|
|
||||||
Linux native containment requires a functional bounded bubblewrap mount/PID probe, not merely an installed executable. The supervisor/worker ready acknowledgement holds user code until containment publication, durable job state, sidecar and exact launch receipt are verified. Filesystem, process-tree and wall-clock dimensions are required; missing mechanisms fail before code execution. Captured init/leader PID plus start tokens and optional pidfd support cleanup; both deaths must be observed. Unknown identity remains unverified, foreign identity is never a signal target, and no later PID lookup can manufacture a launch receipt. Startup timeout includes bootstrap/stdin; cancellation shields spawn cleanup. Optional network/memory/process-count dimensions are not implied enforcement.
|
|
||||||
|
|
||||||
Native background work uses generation-bound launches rather than agent tmux. Operator HTTP tmux remains a compatibility surface. PTY cleanup freezes known descendants before signaling and protects the server group; missing launch PID/token does not trigger PID/group recapture. Unverifiable containment grants remain active; detached jobs with foreign/unverifiable ownership can retire failed without signalling, retaining follow-up history rather than declaring successful cleanup. Publication retirement checks exact launch identity and its caller-specific lifetime rule; foreground startup recovery can also remove publications whose receipt is genuinely absent. Unreadable or malformed nonempty startup state is retained. Dead launch retention/pruning and delivered result retention are bounded lifecycle policies, not proof of task success. Store transaction locks serialize background read/modify/write, not every application store.
|
|
||||||
|
|
||||||
`core.platform_compat.service_home()` uses `Path.home()` on Windows and lazy POSIX real-UID passwd lookup. Successful POSIX lookup keeps a child HOME override from choosing the service cache; the missing-entry/OS-error fallback uses `Path.home()` and can honor that override. Default native child environments allow selected keys with fixed HOME/terminal values; an explicitly supplied subprocess environment is not value-scrubbed. This is separate from inherited built-in MCP environments.
|
|
||||||
|
|
||||||
Host shell/file bridges declare external execution and normalize the admitted URL authority while replacing the operation path and dropping query/fragment components. Every bridge run/read/write/patch/poll/cancel client uses `trust_env=False`. HTTP admission accepts the explicit loopback/host-docker/LAN/CGNAT or actual Docker-gateway cases, not every private network or arbitrary TLS host; this bridge rule does not apply to every other HTTP client. SSH/external raw shell calls cannot borrow native launch/stop authority. Cookbook and scheduled native launches carry sealed backend capabilities; missing capabilities fail closed.
|
|
||||||
|
|
||||||
MCP add-server argument text must decode to a JSON array before mutation; an empty value means an empty array, without an element-type guarantee. Bound backend calls cannot silently reconnect into a different incarnation. Browser timeout abandonment remains no-retry: EOF or partial transport state is not exactly-once execution proof. Email MCP owner and session ContextVars are reset in finally; its owner-filtered listing/index behavior is documented with email ownership.
|
|
||||||
|
|
||||||
## Compatibility Process Evidence
|
|
||||||
|
|
||||||
Python execution confinement validates the read-only interpreter-prefix mounts it relies on instead of treating an arbitrary interpreter path as admitted. Process inspection helpers retain platform-specific uncertainty: unavailable procfs inspection is not proof that a process died, and generic compatibility liveness helpers are not substitutes for the stored exact launch identities required by background execution cleanup.
|
|
||||||
|
|
||||||
## Result Publication And Continuation
|
|
||||||
|
|
||||||
Native execution reports containment, teardown and capture truncation independently. Verified timeout uses exit 124; unverified process death is teardown failure rather than successful cleanup, and Python child-runtime failure cannot hide behind exit zero. Detached supervisors publish result JSON before exit metadata; setup failure releases an unstarted grant and falls back to stderr when its failure log cannot be written. Successful exit does not imply complete captured output.
|
|
||||||
|
|
||||||
PTY teardown freezes its leader identity and verified session group. FOREIGN identity is not signalled, absent/unverifiable identity does not prove death, and a gone leader can permit cleanup of its recorded surviving group without rediscovering a replacement PID. Timeout SSE reports surviving processes; quiet disconnect/error paths log failed cleanup. Generic compatibility tree helpers still require durable callers to request identity verification.
|
|
||||||
|
|
||||||
Background historical lookup does not reap unrelated cached handles: selected running records need their exact frozen PID/start token. Continuation validates immutable job, owner, request, thread and authority linkage before and after awaits, settles the linked launch effect from lifecycle facts rather than output, and persists delivery before marking followed-up. Missing sessions or invalid linkage become terminal_unfollowable; busy sessions/managers retry, and a changed snapshot prevents stale suppression. This is not an atomic lock over the entire chat session.
|
|
||||||
|
|
||||||
Foreground startup retirement retains unreadable/malformed stores. With an existing receipt it requires exact linkage and an observed GONE/FOREIGN manager; live or unverifiable managers retain publication even after child teardown. A genuinely missing receipt also permits retirement of a valid foreground publication because attachment cannot resume. Ordinary producer-finally retirement uses verified teardown. One-use launch reservations are shared across approval copies, so deleting publication does not renew dispatch. Retirement is bookkeeping, not authority to signal or proof of process-tree death. Protected-root overlays are refused after normalization; an external bridge response heuristic does not attest local effects.
|
|
||||||
|
|
||||||
## Image MCP Ownership
|
|
||||||
|
|
||||||
`mcp_servers/image_gen_server.py` reads the hidden trusted-dispatch `_odysseus_owner`, trims blank values to null, stamps `GalleryImage.owner`, and registers a fresh image after gallery commit when an owner exists. There is no image-owner environment fallback. `src/tool_execution.py` removes caller-reserved attribution fields before injecting server owner context for legacy and qualified image calls; direct fallbacks retain the trusted owner. Generated-file access uses the independent fail-closed policy in `gallery-editor-media.md`.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Decide whether `/api/shell/exec`, `/api/shell/stream`, package install, rebuild, and MCP config/OAuth writes should call `_reject_cross_site()` directly.
|
- Decide whether `/api/shell/exec`, `/api/shell/stream`, package install, rebuild, and MCP config/OAuth writes should call `_reject_cross_site()` directly.
|
||||||
- Shell route/service and process-lifecycle tests cover selected execution paths; PTY/tmux/Windows behavior remains platform-dependent.
|
- Add route-level shell exec/stream tests for admin gate, cross-site behavior, empty command, plain exec, timeout, PTY, tmux, and Windows detached fallback.
|
||||||
- Background identity, containment, and follow-up paths have focused tests; restart/platform integration still needs separate validation.
|
- Add background job tests for launch isolation, output truncation, done/failed/timeout/died states, pending follow-ups, and result text.
|
||||||
- Add route-level MCP CRUD/OAuth/disabled-tool tests with a fake manager and temp database.
|
- Add route-level MCP CRUD/OAuth/disabled-tool tests with a fake manager and temp database.
|
||||||
- Add hard per-server disabled MCP execution checks or document disabled tools as prompt/schema filtering only.
|
- Add hard per-server disabled MCP execution checks or document disabled tools as prompt/schema filtering only.
|
||||||
- MCP prompt descriptions cache against both disabled maps and manager generation; downstream index refresh still needs coordinated invalidation.
|
- Make MCP tool indexing sensitive to disabled-map changes, not only manager generation.
|
||||||
|
- Fix stale outer prompt/cache behavior when MCP disabled tools change.
|
||||||
- Add one central truncation layer for MCP result text and images before model re-entry; untrusted-result marking and exact-action continuation approval are now implemented.
|
- Add one central truncation layer for MCP result text and images before model re-entry; untrusted-result marking and exact-action continuation approval are now implemented.
|
||||||
- Decide whether `McpServer.env` and OAuth key files need masking, encryption, and chmod beyond admin-only access.
|
- Decide whether `McpServer.env` and OAuth key files need masking, encryption, and chmod beyond admin-only access.
|
||||||
- Decide whether built-in MCP servers should become owner-aware or remain documented as admin/global compatibility surfaces.
|
- Decide whether built-in MCP servers should become owner-aware or remain documented as admin/global compatibility surfaces.
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# Speech
|
# Speech
|
||||||
|
|
||||||
Last updated: dev@86b886c | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ This spec covers speech behavior in:
|
|||||||
- settings routes in `routes/auth_routes.py`;
|
- settings routes in `routes/auth_routes.py`;
|
||||||
- model endpoint cleanup in `routes/model_routes.py`;
|
- model endpoint cleanup in `routes/model_routes.py`;
|
||||||
- settings/tool aliases in `src/tool_implementations.py`;
|
- settings/tool aliases in `src/tool_implementations.py`;
|
||||||
- frontend modules `static/js/voiceRecorder.js`, `static/js/tts-ai.js`, `static/app.js`, `static/js/chat.js`, `static/js/slashCommands.js`, `static/js/keyboard-shortcuts.js`, `static/js/settings.js`, `static/js/settings/speech.js`, and `static/index.html`;
|
- frontend modules `static/js/voiceRecorder.js`, `static/js/tts-ai.js`, `static/app.js`, `static/js/chat.js`, `static/js/slashCommands.js`, `static/js/keyboard-shortcuts.js`, `static/js/settings.js`, and `static/index.html`;
|
||||||
- optional dependency declarations in `requirements-optional.txt`;
|
- optional dependency declarations in `requirements-optional.txt`;
|
||||||
- runtime cache path `data/tts_cache/`;
|
- runtime cache path `data/tts_cache/`;
|
||||||
- tests covering speech service toggles, TTS speed/cache, STT temp cleanup, upload limits, settings scrubbing, and model endpoint cleanup.
|
- tests covering speech service toggles, TTS speed/cache, STT temp cleanup, upload limits, settings scrubbing, and model endpoint cleanup.
|
||||||
@@ -80,7 +80,7 @@ Route behavior:
|
|||||||
|
|
||||||
Speech providers are global settings under `data/settings.json`, with defaults in `src/settings.py`. Settings reads are scrubbed for non-admin callers, writes are admin-only, and `manage_settings` can change non-secret speech settings through aliases.
|
Speech providers are global settings under `data/settings.json`, with defaults in `src/settings.py`. Settings reads are scrubbed for non-admin callers, writes are admin-only, and `manage_settings` can change non-secret speech settings through aliases.
|
||||||
|
|
||||||
`static/js/settings/speech.js` owns TTS/STT settings initialization and provider selectors; the Settings coordinator imports those initializers. Visible UI state is not complete: backend and JS speech settings exist, the TTS settings card is currently hidden, and the STT settings JS exits when its removed DOM nodes are absent.
|
Visible UI state is not complete: backend and JS speech settings exist, the TTS settings card is currently hidden, and the STT settings JS exits when its removed DOM nodes are absent.
|
||||||
|
|
||||||
`routes.model_routes` clears `tts_provider` and `stt_provider` references when a referenced model endpoint is deleted.
|
`routes.model_routes` clears `tts_provider` and `stt_provider` references when a referenced model endpoint is deleted.
|
||||||
|
|
||||||
|
|||||||
+13
-48
@@ -1,6 +1,6 @@
|
|||||||
# Testing And Devops
|
# Testing And Devops
|
||||||
|
|
||||||
Last updated: dev@c74f475 | 2026-10-15
|
Last updated: dev@e71f8ce | 2026-08-25
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ This spec covers development and validation surfaces in:
|
|||||||
- `scripts/`, `scripts/odysseus`, `scripts/_lib/cli.py`, `scripts/_completion/*`, `scripts/pr_blocker_audit.py`, and `scripts/odysseus-*`;
|
- `scripts/`, `scripts/odysseus`, `scripts/_lib/cli.py`, `scripts/_completion/*`, `scripts/pr_blocker_audit.py`, and `scripts/odysseus-*`;
|
||||||
- GPU helper scripts `scripts/check-docker-gpu.sh` and `scripts/check-docker-amd-gpu.sh`;
|
- GPU helper scripts `scripts/check-docker-gpu.sh` and `scripts/check-docker-amd-gpu.sh`;
|
||||||
- `.github/` templates, workflows, and description-check scripts;
|
- `.github/` templates, workflows, and description-check scripts;
|
||||||
- contributor workflow docs in `CONTRIBUTING.md` and `website/pr-blocker-audit.md`;
|
- contributor workflow docs in `CONTRIBUTING.md` and `docs/pr-blocker-audit.md`;
|
||||||
- platform launchers `launch-windows.ps1`, `launcher.py`, `Odysseus.spec`, `build-windows-portable.ps1`, `start-macos.sh`, `build-macos-app.sh`, and `update_windows.bat`;
|
- platform launchers `launch-windows.ps1`, `launcher.py`, `Odysseus.spec`, `build-windows-portable.ps1`, `start-macos.sh`, `build-macos-app.sh`, and `update_windows.bat`;
|
||||||
- setup/service files such as `setup.py`, `install-service.sh`, and `odysseus-ui.service`.
|
- setup/service files such as `setup.py`, `install-service.sh`, and `odysseus-ui.service`.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Activated-venv `python -m pytest <test path>` is equivalent. System/global `pyte
|
|||||||
|
|
||||||
`tests/conftest.py` inserts the repo root on `sys.path` and conditionally stubs missing heavy/runtime dependencies such as SQLAlchemy, FastAPI, Starlette, Pydantic, httpx, bcrypt, and pyotp. Tests that need real dependencies use explicit imports/skips. Tests that stub `sys.modules`, environment variables, globals, or parent packages must restore them with `monkeypatch` or an equivalent cleanup pattern.
|
`tests/conftest.py` inserts the repo root on `sys.path` and conditionally stubs missing heavy/runtime dependencies such as SQLAlchemy, FastAPI, Starlette, Pydantic, httpx, bcrypt, and pyotp. Tests that need real dependencies use explicit imports/skips. Tests that stub `sys.modules`, environment variables, globals, or parent packages must restore them with `monkeypatch` or an equivalent cleanup pattern.
|
||||||
|
|
||||||
The suite currently contains a growing set of `test_*.py` files. Treat that count as a moving source metric, not a target; focused regression tests are still preferred for narrow changes.
|
The suite currently contains roughly 728 `test_*.py` files. Treat that count as a moving source metric, not a target; focused regression tests are still preferred for narrow changes.
|
||||||
|
|
||||||
Focused regression tests are preferred for narrow behavior changes. Broaden tests when touching shared contracts such as auth, owner filtering, OAuth/token custody, tool output, context building, provider calls, persistence, frontend rendering, or route/API shapes.
|
Focused regression tests are preferred for narrow behavior changes. Broaden tests when touching shared contracts such as auth, owner filtering, OAuth/token custody, tool output, context building, provider calls, persistence, frontend rendering, or route/API shapes.
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ Focused regression tests are preferred for narrow behavior changes. Broaden test
|
|||||||
|
|
||||||
## JS And UI Tests
|
## JS And UI Tests
|
||||||
|
|
||||||
`frontend/modules.json` maps canonical `frontend/src/` modules to committed outputs under `static/`. `scripts/build_frontend.py --check` verifies output freshness/publication attributes; `scripts/check_frontend.mjs` checks architecture/import boundaries. Root `package.json` owns Bombadil, Playwright and Acorn plus photo-editor browser-test scripts; the lockfile owns npm integrity/version state. This is an incremental source migration rather than a bundler-wide conversion.
|
The repo has no frontend build pipeline, npm test script, or type-check script. `package.json` owns Node dependencies for Bombadil and the Anthropic SDK, and `package-lock.json` owns npm integrity/version state.
|
||||||
|
|
||||||
Current frontend/JS validation includes:
|
Current frontend/JS validation includes:
|
||||||
|
|
||||||
@@ -57,14 +57,13 @@ Use `node --check static/js/<changed-file>.js` for syntax checks on changed JS f
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
`requirements.txt` owns core runtime and test dependencies, including `psycopg2-binary`, pytest, pytest-asyncio, MCP, Chroma HTTP client, fastembed, qrcode, and core parsing/search/calendar dependencies.
|
`requirements.txt` owns core runtime and test dependencies, including pytest, pytest-asyncio, MCP, Chroma HTTP client, fastembed, qrcode, and core parsing/search/calendar dependencies.
|
||||||
|
|
||||||
`requirements-optional.txt` owns optional feature dependencies:
|
`requirements-optional.txt` owns optional feature dependencies:
|
||||||
|
|
||||||
- `faster-whisper` for local STT;
|
- `faster-whisper` for local STT;
|
||||||
- `kokoro==0.9.4` and `soundfile` for local TTS on Python 3.11-3.12 only; Kokoro is deliberately skipped on Python 3.13+ because its package metadata excludes those runtimes, and a CUDA-capable torch/GPU is still required at runtime;
|
- `kokoro==0.9.4` and `soundfile` for local TTS on Python 3.11-3.12 only; Kokoro is deliberately skipped on Python 3.13+ because its package metadata excludes those runtimes, and a CUDA-capable torch/GPU is still required at runtime;
|
||||||
- `ddgs` for DDG library support, while provider code can fall back to HTML scraping;
|
- `ddgs` for DDG library support, while provider code can fall back to HTML scraping;
|
||||||
- `rapidocr==3.9.2` and `onnxruntime>=1.20,<2` for local OCR, and `psd-tools` for PSD previews/layer inspection;
|
|
||||||
- `PyMuPDF` for PDF forms/rendering with AGPL implications for a network-served app;
|
- `PyMuPDF` for PDF forms/rendering with AGPL implications for a network-served app;
|
||||||
- `markitdown[docx,pptx,xlsx,xls]` for Office/EPUB extraction, pinned to a release older than 30 days.
|
- `markitdown[docx,pptx,xlsx,xls]` for Office/EPUB extraction, pinned to a release older than 30 days.
|
||||||
|
|
||||||
@@ -97,8 +96,6 @@ Compose variants forward `ODYSSEUS_TTS_CACHE_MAX_BYTES`, defaulting in the servi
|
|||||||
|
|
||||||
Docker does not mount the host Docker socket by default. Mounting it would grant powerful host access and is outside the default trust boundary. `docker/host-docker.yml` is the explicit opt-in overlay and sets `ODYSSEUS_ENABLE_HOST_DOCKER=true`; tests guard that the default and GPU compose files do not enable host Docker accidentally.
|
Docker does not mount the host Docker socket by default. Mounting it would grant powerful host access and is outside the default trust boundary. `docker/host-docker.yml` is the explicit opt-in overlay and sets `ODYSSEUS_ENABLE_HOST_DOCKER=true`; tests guard that the default and GPU compose files do not enable host Docker accidentally.
|
||||||
|
|
||||||
Compose selects `ODYSSEUS_IMAGE` (default GHCR `latest`) alongside a local build and publishes container port 7000 on default host port 7011. SearXNG is tag-and-digest pinned. `docker/host-workspace.yml` explicitly mounts a selected host workspace; `docker/host-network.yml` opts into Linux host networking, resets published ports, and starts uvicorn on the configured host bind/port. These overlays expand access beyond default deployment. The image includes Chromium, Noto CJK fonts, network inspection utilities, pinned `agent-browser@0.35.0`, and redistribution notices. Entrypoint ownership repair touches HuggingFace cache roots without traversing existing model files.
|
|
||||||
|
|
||||||
## GPU And Platform
|
## GPU And Platform
|
||||||
|
|
||||||
Base `docker-compose.yml` plus `docker/gpu.nvidia.yml` or `docker/gpu.amd.yml` are the GPU source of truth. Top-level `docker-compose.gpu-nvidia.yml` and `docker-compose.gpu-amd.yml` are standalone mirrors for stack-management UIs that accept one compose file. `tests/test_gpu_compose_standalone.py` guards drift between those forms.
|
Base `docker-compose.yml` plus `docker/gpu.nvidia.yml` or `docker/gpu.amd.yml` are the GPU source of truth. Top-level `docker-compose.gpu-nvidia.yml` and `docker-compose.gpu-amd.yml` are standalone mirrors for stack-management UIs that accept one compose file. `tests/test_gpu_compose_standalone.py` guards drift between those forms.
|
||||||
@@ -148,7 +145,7 @@ When route/API behavior changes, check whether a matching CLI script depends on
|
|||||||
|
|
||||||
## GitHub Metadata
|
## GitHub Metadata
|
||||||
|
|
||||||
`.github/` owns issue/PR templates, a copyable PR review template, description-check workflows, security/governance workflows, Docker publishing, and CI. Current CI runs on pushes to `main` and `dev` plus pull requests, compiles Python, checks committed frontend outputs, validates frontend architecture/syntax, emits report-only focused-test guidance, and partitions pytest across four file-based shards through `tests/_shards.py`. Pytest setup requires functional bubblewrap PID/mount containment, FFmpeg and Chromium. Its prose-only bypass excludes `website/` and `assets/branding/`; those published-file contracts remain tested.
|
`.github/` owns issue/PR templates, a copyable PR review template, description-check workflows, security/governance workflows, Docker publishing, and CI. Current CI runs on pushes to `main` and `dev` plus pull requests, compiles Python with `python -m compileall`, syntax-checks first-party JS with `node --check`, emits focused-test guidance for changed code, and runs the configured `python -m pytest -q` scope as an authoritative failing job; pytest still skips documentation-only changes.
|
||||||
|
|
||||||
`CONTRIBUTING.md` owns the branch model: PRs target `dev`; `main` is the curated user-running branch fast-forwarded from stable `dev` commits. Contributors who accidentally target `main` should retarget the PR base without rebasing.
|
`CONTRIBUTING.md` owns the branch model: PRs target `dev`; `main` is the curated user-running branch fast-forwarded from stable `dev` commits. Contributors who accidentally target `main` should retarget the PR base without rebasing.
|
||||||
|
|
||||||
@@ -172,9 +169,9 @@ Issue description checks:
|
|||||||
- update a bot comment and swap status labels;
|
- update a bot comment and swap status labels;
|
||||||
- remove the workflow-owned review label when an issue closes so closed issues do not retain stale readiness state.
|
- remove the workflow-owned review label when an issue closes so closed issues do not retain stale readiness state.
|
||||||
|
|
||||||
Security metadata includes container Trivy SARIF upload, Dockerfile lint, dependency review, secret scan, workflow security linting, GitHub default-setup CodeQL, Dependabot metadata, and hardened PR/issue description checks that avoid unsafe head-branch execution. `website/security-ci.md` documents CodeQL as a dynamic GitHub default-setup workflow; the repo should not add a checked-in CodeQL workflow while that default setup is active.
|
Security metadata includes container Trivy SARIF upload, Dockerfile lint, dependency review, secret scan, workflow security linting, GitHub default-setup CodeQL, Dependabot metadata, and hardened PR/issue description checks that avoid unsafe head-branch execution. `docs/security-ci.md` documents CodeQL as a dynamic GitHub default-setup workflow; the repo should not add a checked-in CodeQL workflow while that default setup is active.
|
||||||
|
|
||||||
`scripts/pr_blocker_audit.py` is a read-only maintainer/contributor triage helper documented in `website/pr-blocker-audit.md`. It can fetch or ingest open PR metadata, estimate hot files and possible duplicate groups, and emit Markdown, JSON, or terminal reports. Its duplicate/blocker output is advisory, not an authority that a PR is blocked.
|
`scripts/pr_blocker_audit.py` is a read-only maintainer/contributor triage helper documented in `docs/pr-blocker-audit.md`. It can fetch or ingest open PR metadata, estimate hot files and possible duplicate groups, and emit Markdown, JSON, or terminal reports. Its duplicate/blocker output is advisory, not an authority that a PR is blocked.
|
||||||
|
|
||||||
Before posting PRs or issues, compare drafts against current templates on latest `main` or current `dev` as appropriate for the target. Keep unpublished drafts and raw related-search exports out of tracked implementation specs unless intentionally promoted.
|
Before posting PRs or issues, compare drafts against current templates on latest `main` or current `dev` as appropriate for the target. Keep unpublished drafts and raw related-search exports out of tracked implementation specs unless intentionally promoted.
|
||||||
|
|
||||||
@@ -204,50 +201,18 @@ Run the app for user-facing or integration changes. Unit tests and syntax checks
|
|||||||
|
|
||||||
`tests/helpers/` owns reusable test scaffolding. `cli_loader.load_script()` loads CLI files without running their `main()` entrypoint. `db_stubs` owns small DB stand-ins for tests that should not import a real app database. `import_state` owns conservative `sys.modules` and parent-module-attribute restoration for tests that install fake modules or import route files under alternate stubs. `tests/README.md` documents helper conventions and review expectations.
|
`tests/helpers/` owns reusable test scaffolding. `cli_loader.load_script()` loads CLI files without running their `main()` entrypoint. `db_stubs` owns small DB stand-ins for tests that should not import a real app database. `import_state` owns conservative `sys.modules` and parent-module-attribute restoration for tests that install fake modules or import route files under alternate stubs. `tests/README.md` documents helper conventions and review expectations.
|
||||||
|
|
||||||
`tests/test_runtime_behavior_regressions.py` drives the agent loop with a fake streaming model to check offered tools, clarification/plan policy, explicit native request scope and mocked browser cleanup. Negative-web cases require a model call, an answer delta and terminal completion while withholding web tools. These deterministic runtime assertions do not establish live-model or browser acceptance.
|
|
||||||
|
|
||||||
## Harness And Publication Boundaries
|
|
||||||
|
|
||||||
`tests/conftest.py` defaults the static origin to an ephemeral loopback port and publishes/restores `ODYSSEUS_TEST_STATIC_ORIGIN`; an explicit validated port can still collide. Threaded serving avoids idle-connection head-of-line blocking. Per-process random private data/mail/embedding/cache/temp paths and optional xdist basetemp avoid sharing runtime state; explicit basetemp wins. Disposable database helpers use isolated factories/cleanup rather than ORM reloads. A teardown guard removes and fails newly leaked bare `src`/`core` modules, using exact ModuleType/falsey-file detection; preexisting modules and mocks are outside that guard. The Unix socket path length fixture is character-bounded, not a universal byte-length proof.
|
|
||||||
|
|
||||||
`tests/_shards.py` partitions collected files deterministically by weights and lexical tie-breaks across four CI shards; deselection occurs after collection/import. Optional parallel smoke and serial-only guards do not make every test xdist-safe. Pytest CI uses Node 20, locked npm dependencies, cached Chromium downloads keyed by OS/lockfile, system browser libraries, FFmpeg and a functional unprivileged bubblewrap probe. It uses setup-uv cache without automatic prune and pytest skip reasons/duration reporting. These workflow provisions are not evidence that every browser, media or containment test passed on another host.
|
|
||||||
|
|
||||||
`tests/helpers/document_source.py` provides ordered implementation membership and lexical construct extraction; email JS helper enumeration is immediate rather than recursive. Stylesheet helpers follow shell order and cache identities; keyframe/hygiene/import/procfs guards are bounded textual or AST checks, not full semantic control-flow/browser proofs. The computed-style fixture, browser artifacts and reported historical suite counts remain specific to their recorded environments.
|
|
||||||
|
|
||||||
`scripts/odysseus-dev` owns separate development data/database/browser-cache paths, path-hashed port blocks, service/reserved-port refusal, readiness login and fingerprinted stop state. Hash collisions, released fallback-port races and environment-derived defaults prevent a universal isolation guarantee. `scripts/odysseus-smoke` exercises deterministic model/email HTTP scenarios and reports declared gaps; it is not full browser/product acceptance and cleanup/reused-instance state remains scoped. `scripts/ref_parity_audit.py` compares heuristic added-line probes and exact filtered tree-path sets, not behavioral equivalence; mutable refs can be reread during a run. `scripts/generate_env_reference.py` checks generated reference bytes and handwritten notes from bounded AST/helper/regex discovery, not exhaustive runtime environment use; malformed Python can yield no detected rows and source citations are file-based.
|
|
||||||
|
|
||||||
`.github/workflows/deploy-pages.yml` builds `website/` into `_site`; preview encoding targets website assets and branding lives under `assets/branding/`. Docker excludes both website and branding source roots. The macOS app script uses moved branding with best-effort `sips` icon generation; no native build or license clearance follows from source inspection. Docker publishing emits version/short-SHA trace tags and inspects those plus latest, without registry-enforced immutability. Trivy jobs use the Docker Buildx driver and free local build cache before vulnerability database work; this is CI lifecycle behavior, not an instruction to prune controller state.
|
|
||||||
|
|
||||||
Description checks retain the public numeric Linked Issue requirement; an exact maintainer-preview repository allows a beginning `N/A` value while still requiring the section after comment stripping. This checks syntax, not issue existence or branch rulesets. Dependency-review blocking applies to canonical repository pull requests; fork pip-audit advice is a separate policy. None of these workflows grants publication authority.
|
|
||||||
|
|
||||||
Maintained frontend publication requires repository-owned LF text attributes on both mapped paths, checked through isolated Git attribute evaluation. It validates the complete plan before writes, copies bytes without rewriting imports or bundling and never deletes unowned static assets; publication is not a transactional live deployment. Acorn checks manifest-selected dependency direction, canonical URL identity, used legacy exceptions, exact precache arrays and cycles, not all legacy browser behavior.
|
|
||||||
|
|
||||||
## Chroma Mount And Focused Safety Regressions
|
|
||||||
|
|
||||||
All three Compose variants mount `chromadb-data:/data`, matching the current image configuration rather than pre-1.0 `/chroma/chroma`. Existing writable-layer data needs rescue before container recreation; `docs/chromadb-volume-migration.md` describes writer shutdown/cold backup, preserved backup image, SQLite/HNSW checks, empty-volume seeding, isolated staging and count comparison. `website/backup-restore.md` links that procedure. Static YAML/document tests cover declared paths and safety markers; they do not execute or certify migration against a live Chroma image.
|
|
||||||
|
|
||||||
Focused regressions now cover invalid history truncation preserving messages, failed bulk Trash moves/selective expunge, disabled-auth app configuration gates, durable cleanup message counts/delete-boundary races and generated-image ownership/cache/attribution. Test presence and historical counts do not establish a new successful run; runtime validation evidence is separate.
|
|
||||||
|
|
||||||
## Notes Extraction Validation Surface
|
|
||||||
|
|
||||||
Notes tests include domain/characterization/application/infrastructure/reminder/workflow contracts, recurrence differential cases across timezones, raw-state cloneability, draft timing and selected-context acceptance. Browser fixtures exercise canonical/facade identity, desktop/mobile/tablet interactions, teardown and first-time offline graph loading; source guards now read feature modules through `tests/helpers/notes_source.py`. `scripts/verify_frontend_runtime.py` checks all manifest outputs in a Python-only distribution, while `scripts/verify_notes_browser_runtime.py` probes an authenticated temporary native app. These require their declared dependencies/browser environment; committed receipts and screenshots are historical evidence, not a fresh acceptance run. Runtime serving still requires no frontend build or Node installation.
|
|
||||||
|
|
||||||
## Skills Extraction And Upload Contract Tests
|
|
||||||
|
|
||||||
`tests/test_skills_audit_characterization.py` freezes 22 route declarations, 28 Python interfaces and canonical OpenAPI (17 paths, 22 operations, five schemas) from committed fixtures; constrained fields, supported default factories, model configuration and aliases are checked structurally. Unsupported factories fail without execution. `tests/test_skills_audit_services.py` exercises real route/service wiring with offline model/agent/manager substitutes, including prompt seams, owner/request authority, one-use approvals and cancellation/stream closure. Fixtures do not prove arbitrary validators, live execution or authorization by themselves. ReDoS guards now scan/import `services/skills/evaluation.py`. The Gitleaks exception is constrained to one exact historical digest, the generic-api-key rule and two named paths; this maintenance does not claim a new full-history scan.
|
|
||||||
|
|
||||||
`tests/test_upload_no_login.py` covers disabled-auth access after setup, missing/unconfigured manager failures, token ownership/scopes, legacy ownerless uploads and OCR preservation on rejection. Its loopback-host parametrization does not itself change TestClient transport identity, so source behavior and narrower assertions remain the evidence for absence of a loopback bypass.
|
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- Fresh install smoke coverage across Linux native, Docker, macOS native/app, Windows native, WSL/Git Bash, missing Node/npm, missing Chroma service, and GPU overlays remains a roadmap item.
|
- Fresh install smoke coverage across Linux native, Docker, macOS native/app, Windows native, WSL/Git Bash, missing Node/npm, missing Chroma service, and GPU overlays remains a roadmap item.
|
||||||
- Frontend source/output and architecture gates exist; they do not establish full browser or platform correctness.
|
- There is no frontend build/type-check/npm test pipeline.
|
||||||
- Real deployment, GPU and cross-platform install verification still require separate runs beyond CI gates.
|
- CI now covers Python compile, first-party JS syntax, focused-test guidance,
|
||||||
|
and pytest smoke; it does not cover Docker compose validation, launcher smoke
|
||||||
|
tests, browser/module-graph execution, or platform installs.
|
||||||
- Optional dependency behavior is broad; remaining gaps include local STT missing-`faster-whisper`, Kokoro's Python/GPU degraded matrix, and provider/OAuth combinations not covered by focused tests.
|
- Optional dependency behavior is broad; remaining gaps include local STT missing-`faster-whisper`, Kokoro's Python/GPU degraded matrix, and provider/OAuth combinations not covered by focused tests.
|
||||||
- GitHub description-check scripts and `scripts/pr_blocker_audit.py` need continued local fixtures for section parsing, placeholder stripping, label swaps, workflow-safe behavior, and duplicate/hot-file heuristics.
|
- GitHub description-check scripts and `scripts/pr_blocker_audit.py` need continued local fixtures for section parsing, placeholder stripping, label swaps, workflow-safe behavior, and duplicate/hot-file heuristics.
|
||||||
- Spec bootstrap rules lack meta tests for reading `_readme.md`, spec shape, `.env*` handling, draft/report placement, and shared helper conventions.
|
- Spec bootstrap rules lack meta tests for reading `_readme.md`, spec shape, `.env*` handling, draft/report placement, and shared helper conventions.
|
||||||
- NVIDIA helper install/`.env` mutation paths and real Docker/GPU startup are not covered by local tests.
|
- NVIDIA helper install/`.env` mutation paths and real Docker/GPU startup are not covered by local tests.
|
||||||
- Bash/Zsh completion behavior is not covered.
|
- Bash/Zsh completion behavior is not covered.
|
||||||
- `tests/KNOWN_FAILURES.md` records observed environment-dependent full-suite failures and prerequisites; its measured basis is historical, so verify applicability rather than treating it as a passing waiver.
|
- There is no canonical full-suite known-failing/flaky ledger.
|
||||||
- There is no central CLI redaction/sensitive-output regression matrix across backup, logs, mail, MCP, tasks, and webhook scripts.
|
- There is no central CLI redaction/sensitive-output regression matrix across backup, logs, mail, MCP, tasks, and webhook scripts.
|
||||||
- Dependency/image pinning policy is mixed: Python requirements are mostly unpinned, SearXNG is pinned, Chroma image currently uses `latest`, npm uses a lockfile, and browser MCP uses cache-gated `@playwright/mcp@latest`.
|
- Dependency/image pinning policy is mixed: Python requirements are mostly unpinned, SearXNG is pinned, Chroma image currently uses `latest`, npm uses a lockfile, and browser MCP uses cache-gated `@playwright/mcp@latest`.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""In-flight activity counters (heavy image ops) for safe restarts/observability.
|
||||||
|
|
||||||
|
A deploy restart kills any in-flight long image op (a diffusion edit is 60-120 s),
|
||||||
|
so the operator needs to know whether the process is busy before issuing
|
||||||
|
``systemctl --user restart``. These counters are process-local and cheap.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_image_ops = 0
|
||||||
|
_image_ops_by_name: Dict[str, int] = {}
|
||||||
|
_started_at = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def begin_image_op(name: str = "image") -> None:
|
||||||
|
global _image_ops
|
||||||
|
with _lock:
|
||||||
|
_image_ops += 1
|
||||||
|
_image_ops_by_name[name] = _image_ops_by_name.get(name, 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def end_image_op(name: str = "image") -> None:
|
||||||
|
global _image_ops
|
||||||
|
with _lock:
|
||||||
|
_image_ops = max(0, _image_ops - 1)
|
||||||
|
if name in _image_ops_by_name:
|
||||||
|
_image_ops_by_name[name] -= 1
|
||||||
|
if _image_ops_by_name[name] <= 0:
|
||||||
|
_image_ops_by_name.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def image_op(name: str = "image"):
|
||||||
|
begin_image_op(name)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
end_image_op(name)
|
||||||
|
|
||||||
|
|
||||||
|
def activity() -> Dict[str, object]:
|
||||||
|
with _lock:
|
||||||
|
return {
|
||||||
|
"image_ops": _image_ops,
|
||||||
|
"image_ops_by_name": dict(_image_ops_by_name),
|
||||||
|
"uptime_s": round(time.time() - _started_at, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def heavy_image_ops_active() -> int:
|
||||||
|
with _lock:
|
||||||
|
return _image_ops
|
||||||
|
|
||||||
|
|
||||||
|
def busy() -> bool:
|
||||||
|
return heavy_image_ops_active() > 0
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
"""In-process agent tools for the scoped ``photo_editor`` capability family.
|
||||||
|
|
||||||
|
These handlers run as the logged-in owner and drive the image editor over the
|
||||||
|
owner-scoped in-process bridge (``/api/editor/bridge/*``) plus the app's
|
||||||
|
existing image transforms (``/api/image/*``). They are only offered to the
|
||||||
|
model when the ``photo_editor`` family is active (the Quick Edit popup or an
|
||||||
|
explicit photo-edit chat turn), never in the general tool list.
|
||||||
|
|
||||||
|
Loopback calls carry the internal token and impersonate ``owner`` (middleware
|
||||||
|
app.py), so the editor bridge resolves the same ``owner:session`` scope the
|
||||||
|
browser subscriber uses.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.tools._common import _INTERNAL_BASE, _internal_headers
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_message() -> str:
|
||||||
|
try:
|
||||||
|
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||||
|
|
||||||
|
return IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
"Removing/adding objects needs an image generation provider; enable one "
|
||||||
|
"in Settings > Models."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_available(owner: Optional[str]) -> bool:
|
||||||
|
try:
|
||||||
|
from src.ai_interaction import image_editing_provider_available
|
||||||
|
|
||||||
|
return bool(image_editing_provider_available(owner))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Local diffusion/image servers can take 90-110s per image (8 steps) and
|
||||||
|
# longer on a cold start; the previous 120s default timed out mid-generation.
|
||||||
|
_EDITOR_TIMEOUT = 300.0
|
||||||
|
# A mask covering less than this fraction of the image cannot drive a useful
|
||||||
|
# regional inpaint (tiny figures, empty SAM output): fall back to grounding or
|
||||||
|
# whole-image instruction mode instead.
|
||||||
|
_MIN_MASK_FRACTION = 0.002
|
||||||
|
_AI_OPS = {
|
||||||
|
"remove-bg": "/api/image/remove-bg",
|
||||||
|
"upscale": "/api/image/upscale-local",
|
||||||
|
"denoise": "/api/image/denoise",
|
||||||
|
"enhance-face": "/api/image/enhance-face",
|
||||||
|
"inpaint": "/api/image/inpaint",
|
||||||
|
"generative": "/api/image/inpaint",
|
||||||
|
}
|
||||||
|
_PLACE_OP = "layers.placeImage"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(content: Any) -> Dict[str, Any]:
|
||||||
|
raw = content if isinstance(content, str) else json.dumps(content or {})
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw.strip() else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = {}
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||||
|
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
|
||||||
|
|
||||||
|
|
||||||
|
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
||||||
|
headers = {**_internal_headers(owner), "Content-Type": "application/json"}
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.post(f"{_INTERNAL_BASE}{path}", json=payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"raw": resp.text}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_data_url(value: Any) -> Optional[str]:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if value.startswith("data:"):
|
||||||
|
return value
|
||||||
|
if value.startswith("/"):
|
||||||
|
return None
|
||||||
|
return f"data:image/png;base64,{value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _data_url_bytes(data_url: str) -> bytes:
|
||||||
|
_, _, b64 = data_url.partition(",")
|
||||||
|
return base64.b64decode(b64)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
||||||
|
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
||||||
|
from core.database import GalleryImage, SessionLocal
|
||||||
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
|
(root / filename).write_bytes(image_bytes)
|
||||||
|
new_id = str(uuid.uuid4())
|
||||||
|
width = height = 0
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(io.BytesIO(image_bytes)) as img:
|
||||||
|
width, height = img.size
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
size = f"{width}x{height}" if width and height else ""
|
||||||
|
# The editor bridge uses an owner-scoped session id (e.g. "pewds:3")
|
||||||
|
# that is not a chat-sessions row. GalleryImage.session_id is a FK to
|
||||||
|
# sessions.id, so drop an unknown id instead of raising IntegrityError.
|
||||||
|
if session_id:
|
||||||
|
from core.database import Session as _ChatSession
|
||||||
|
|
||||||
|
try:
|
||||||
|
known = db.query(_ChatSession.id).filter(_ChatSession.id == session_id).first()
|
||||||
|
except Exception:
|
||||||
|
known = None
|
||||||
|
if known is None:
|
||||||
|
session_id = None
|
||||||
|
derived = GalleryImage(
|
||||||
|
id=new_id,
|
||||||
|
filename=filename,
|
||||||
|
prompt=prompt or None,
|
||||||
|
caption=None,
|
||||||
|
model=model,
|
||||||
|
size=size or None,
|
||||||
|
quality=None,
|
||||||
|
tags=None,
|
||||||
|
ai_tags=None,
|
||||||
|
session_id=session_id,
|
||||||
|
album_id=None,
|
||||||
|
owner=owner,
|
||||||
|
file_hash=hashlib.sha256(image_bytes).hexdigest(),
|
||||||
|
file_size=len(image_bytes),
|
||||||
|
width=width or None,
|
||||||
|
height=height or None,
|
||||||
|
)
|
||||||
|
db.add(derived)
|
||||||
|
db.commit()
|
||||||
|
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool:
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"dataUrl": data_url,
|
||||||
|
"name": name,
|
||||||
|
"masked": bool(masked),
|
||||||
|
"blend": blend or "normal",
|
||||||
|
"opacity": opacity,
|
||||||
|
}
|
||||||
|
# Forward the edit mode so the client can build a difference mask for
|
||||||
|
# instruction-mode results: the whole image was regenerated under an
|
||||||
|
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||||
|
# client keeps only the changed region) instead of the new layer overwriting
|
||||||
|
# the entire canvas. Omit the keys entirely when no mode is given so the
|
||||||
|
# client keeps its current behavior (e.g. generate_image must not diff-mask).
|
||||||
|
if mode:
|
||||||
|
params["mode"] = mode
|
||||||
|
params["differenceMask"] = mode == "instruction"
|
||||||
|
if mode == "instruction":
|
||||||
|
# Exclude a thin edge band from the client difference mask: full-frame
|
||||||
|
# regenerations can shift pixels along the border.
|
||||||
|
params["edgeExcludePx"] = 3
|
||||||
|
payload = {
|
||||||
|
"session": session,
|
||||||
|
"op": _PLACE_OP,
|
||||||
|
"params": params,
|
||||||
|
"wait": True,
|
||||||
|
"timeout": 60,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
||||||
|
return data.get("ok") is not False
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning("editor layer placement failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_selection_mask(owner: Optional[str], session: str) -> Optional[str]:
|
||||||
|
"""Return the editor's current selection as a data-URL mask, or None.
|
||||||
|
|
||||||
|
Generative ops (inpaint/generative) need a mask; the planner/judge may not
|
||||||
|
have supplied one, so fall back to whatever is selected in the editor.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"session": session,
|
||||||
|
"op": "selection.exportMask",
|
||||||
|
"params": {},
|
||||||
|
"wait": True,
|
||||||
|
"timeout": 30,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await _post("/api/editor/bridge/command", payload, owner, timeout=45)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning("editor selection mask fetch failed: %s", exc)
|
||||||
|
return None
|
||||||
|
result = data.get("result") if isinstance(data, dict) else None
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return _as_data_url(result.get("mask"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_white_fraction(data_url: Optional[str]) -> float:
|
||||||
|
"""Fraction of a grayscale mask that is selected (white, >=128)."""
|
||||||
|
if not data_url:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
|
||||||
|
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))).convert("L") as im:
|
||||||
|
total = im.width * im.height
|
||||||
|
if not total:
|
||||||
|
return 0.0
|
||||||
|
hist = im.histogram()
|
||||||
|
return sum(hist[128:]) / total
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _image_is_blank(data_url: str) -> bool:
|
||||||
|
"""True when a result has an alpha channel that is essentially transparent."""
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
|
||||||
|
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))) as im:
|
||||||
|
if im.mode not in ("RGBA", "LA") and "transparency" not in im.info:
|
||||||
|
return False
|
||||||
|
alpha = im.convert("RGBA").getchannel("A")
|
||||||
|
hist = alpha.histogram()
|
||||||
|
total = sum(hist)
|
||||||
|
return total > 0 and (sum(hist[1:]) / total) < 0.001
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rect_mask_data_url(image_data_url: str, box: List[float], *, pad_frac: float = 0.15) -> Optional[str]:
|
||||||
|
"""Build a rectangular mask data URL from a [x1,y1,x2,y2] box, dilated.
|
||||||
|
|
||||||
|
Used when the grounding detector finds an object but SAM returns an empty
|
||||||
|
mask (tiny figures): a dilated box guarantees a usable region.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage, ImageDraw
|
||||||
|
|
||||||
|
with _PILImage.open(io.BytesIO(_data_url_bytes(image_data_url))) as im:
|
||||||
|
w, h = im.size
|
||||||
|
x1, y1, x2, y2 = (float(v) for v in box)
|
||||||
|
bw, bh = x2 - x1, y2 - y1
|
||||||
|
x1, y1 = max(0.0, x1 - bw * pad_frac), max(0.0, y1 - bh * pad_frac)
|
||||||
|
x2, y2 = min(float(w), x2 + bw * pad_frac), min(float(h), y2 + bh * pad_frac)
|
||||||
|
mask = _PILImage.new("L", (w, h), 0)
|
||||||
|
ImageDraw.Draw(mask).rectangle([x1, y1, x2, y2], fill=255)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
mask.save(buf, "PNG")
|
||||||
|
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _ground_box(owner: Optional[str], image_data_url: str, label: str) -> Optional[List[float]]:
|
||||||
|
"""Use the in-app grounding detector (OWL-ViT via /api/image/mask) for a box."""
|
||||||
|
try:
|
||||||
|
data = await _post("/api/image/mask", {"image": image_data_url, "text": label}, owner, timeout=90)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning("editor grounding box failed: %s", exc)
|
||||||
|
return None
|
||||||
|
box = data.get("bbox") if isinstance(data, dict) else None
|
||||||
|
if isinstance(box, (list, tuple)) and len(box) == 4:
|
||||||
|
try:
|
||||||
|
return [float(v) for v in box]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_keep_clause(text: str) -> str:
|
||||||
|
"""Append a keep-the-subject clause when the prompt lacks one."""
|
||||||
|
body = str(text or "").strip()
|
||||||
|
if not body:
|
||||||
|
return body
|
||||||
|
if re.search(r"\b(keep|preserve|leave|unchanged|identical|same)\b", body, re.I):
|
||||||
|
return body
|
||||||
|
return body.rstrip(". ") + ". Keep the main subject and everything else in the image exactly the same."
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_label(args: Dict[str, Any], prompt: str) -> str:
|
||||||
|
"""Best-effort object noun for grounding a regional removal."""
|
||||||
|
label = str(args.get("label") or args.get("object") or "").strip()
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
m = re.search(
|
||||||
|
r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out)\s+(?:the\s+|a\s+|an\s+)?"
|
||||||
|
r"([a-z][a-z \-]{1,30}?)(?:\s+(?:on|in|at|from|to|near|behind|beside|by)\b|[.,;!?]|$)",
|
||||||
|
prompt or "",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
noun = m.group(1).strip().lower()
|
||||||
|
if noun and noun not in {"background", "bg"}:
|
||||||
|
return noun
|
||||||
|
for noun in ("person", "people", "man", "woman", "car", "dog", "sign", "logo"):
|
||||||
|
if re.search(rf"\b{re.escape(noun)}\b", prompt or "", re.I):
|
||||||
|
return "person" if noun in {"person", "people", "man", "woman"} else noun
|
||||||
|
return "object"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanEditTool:
|
||||||
|
"""Plan an image edit as ordered editor operations (no side effects)."""
|
||||||
|
|
||||||
|
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||||
|
args = _parse(content)
|
||||||
|
prompt = str(args.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return "plan_edit: invalid", {"error": "plan_edit needs a non-empty `prompt`.", "exit_code": 1}
|
||||||
|
state = args.get("state") if isinstance(args.get("state"), dict) else {}
|
||||||
|
try:
|
||||||
|
data = await _post(
|
||||||
|
"/api/editor/plan",
|
||||||
|
{"prompt": prompt, "state": state, "mode": "plan", "session": _session(args, ctx)},
|
||||||
|
ctx.get("owner"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return "plan_edit: error", {"error": f"plan_edit failed: {exc}", "exit_code": 1}
|
||||||
|
steps = data.get("steps") or []
|
||||||
|
reply = str(data.get("reply") or "")
|
||||||
|
return f"plan_edit: {len(steps)} step(s)", {
|
||||||
|
"output": reply or f"Planned {len(steps)} editor step(s).",
|
||||||
|
"reply": reply,
|
||||||
|
"planner": data.get("planner"),
|
||||||
|
"steps": steps,
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RunEditorCommandTool:
|
||||||
|
"""Run a single validated editor op in the browser via the owner bridge."""
|
||||||
|
|
||||||
|
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||||
|
args = _parse(content)
|
||||||
|
op = str(args.get("op") or "").strip()
|
||||||
|
if not op:
|
||||||
|
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
|
||||||
|
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||||
|
payload = {
|
||||||
|
"session": _session(args, ctx),
|
||||||
|
"op": op,
|
||||||
|
"params": params,
|
||||||
|
"wait": bool(args.get("wait", True)),
|
||||||
|
"timeout": float(args.get("timeout") or 60),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
||||||
|
except Exception as exc:
|
||||||
|
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
|
||||||
|
ok = data.get("ok")
|
||||||
|
result = data.get("result")
|
||||||
|
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||||
|
return f"run_editor_command: {op}", {
|
||||||
|
"output": summary[:4000],
|
||||||
|
"result": result,
|
||||||
|
"screenshot": data.get("screenshot"),
|
||||||
|
"ok": ok,
|
||||||
|
"exit_code": 0 if ok is not False else 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GetEditorScreenshotTool:
|
||||||
|
"""Capture the current editor canvas as a data URL."""
|
||||||
|
|
||||||
|
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||||
|
args = _parse(content)
|
||||||
|
payload = {
|
||||||
|
"session": _session(args, ctx),
|
||||||
|
"op": "screenshot",
|
||||||
|
"params": {},
|
||||||
|
"wait": True,
|
||||||
|
"timeout": float(args.get("timeout") or 30),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"), timeout=45)
|
||||||
|
except Exception as exc:
|
||||||
|
return "get_editor_screenshot: error", {"error": f"get_editor_screenshot failed: {exc}", "exit_code": 1}
|
||||||
|
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||||
|
shot = data.get("screenshot") or result.get("dataUrl")
|
||||||
|
if not shot:
|
||||||
|
return "get_editor_screenshot: unavailable", {
|
||||||
|
"error": "No editor screenshot was returned (is the editor open?).",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
return "get_editor_screenshot", {"output": "Captured the editor canvas.", "screenshot": shot, "exit_code": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class AiEditImageTool:
|
||||||
|
"""Apply an AI image transform (remove-bg/upscale/denoise/enhance/inpaint)."""
|
||||||
|
|
||||||
|
async def execute(self, content: Any, ctx: Dict[str, Any]):
|
||||||
|
args = _parse(content)
|
||||||
|
_t = {"start": time.perf_counter()}
|
||||||
|
op = str(args.get("op") or "").strip().lower()
|
||||||
|
if op not in _AI_OPS:
|
||||||
|
return "ai_edit_image: invalid", {
|
||||||
|
"error": f"ai_edit_image `op` must be one of {sorted(_AI_OPS)}.",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
owner = ctx.get("owner")
|
||||||
|
if op in ("inpaint", "generative") and not _provider_available(owner):
|
||||||
|
return "ai_edit_image: provider required", {
|
||||||
|
"error": _provider_message(),
|
||||||
|
"failure_kind": "image_provider_unavailable",
|
||||||
|
"needs_provider": True,
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
session = _session(args, ctx)
|
||||||
|
image = _as_data_url(args.get("image"))
|
||||||
|
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
||||||
|
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
||||||
|
resp.raise_for_status()
|
||||||
|
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
||||||
|
except Exception:
|
||||||
|
image = None
|
||||||
|
used_canvas = False
|
||||||
|
if not image:
|
||||||
|
shot = await GetEditorScreenshotTool().execute(json.dumps({"session": session}), ctx)
|
||||||
|
image = (shot[1] or {}).get("screenshot") if isinstance(shot, tuple) else None
|
||||||
|
used_canvas = bool(image)
|
||||||
|
if not image:
|
||||||
|
return "ai_edit_image: no image", {
|
||||||
|
"error": "ai_edit_image needs an `image` (data URL or same-origin URL) or an open editor.",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
_t["image"] = time.perf_counter()
|
||||||
|
payload: Dict[str, Any] = {"image": image}
|
||||||
|
model_choice = str(args.get("model") or "").strip()
|
||||||
|
mask_used = False
|
||||||
|
place_mode: Optional[str] = None
|
||||||
|
if op == "upscale":
|
||||||
|
payload["scale"] = int(args.get("scale") or 2)
|
||||||
|
elif op == "denoise":
|
||||||
|
payload["strength"] = float(args.get("strength") or 0.5)
|
||||||
|
elif op in ("inpaint", "generative"):
|
||||||
|
payload["prompt"] = str(args.get("prompt") or "").strip()
|
||||||
|
if model_choice:
|
||||||
|
# inpaint_proxy reads `_model` to route to a specific model.
|
||||||
|
payload["_model"] = model_choice
|
||||||
|
mode = str(args.get("mode") or "instruction").strip().lower()
|
||||||
|
_steps = int(args.get("steps") or 0)
|
||||||
|
if _steps > 0:
|
||||||
|
payload["steps"] = _steps
|
||||||
|
mask_used = False
|
||||||
|
mask = None
|
||||||
|
if mode == "masked":
|
||||||
|
mask = _as_data_url(args.get("mask"))
|
||||||
|
if not mask:
|
||||||
|
mask = await _fetch_selection_mask(owner, session)
|
||||||
|
# An empty/tiny SAM mask on small figures is useless: drop it.
|
||||||
|
if mask and _mask_white_fraction(mask) < _MIN_MASK_FRACTION:
|
||||||
|
mask = None
|
||||||
|
if not mask:
|
||||||
|
# Fall back to the grounding detector (OWL-ViT): a dilated
|
||||||
|
# box mask around the matching object.
|
||||||
|
box = await _ground_box(owner, image, _mask_label(args, payload["prompt"]))
|
||||||
|
if box:
|
||||||
|
candidate = _rect_mask_data_url(image, box, pad_frac=0.15)
|
||||||
|
if candidate and _mask_white_fraction(candidate) >= _MIN_MASK_FRACTION:
|
||||||
|
mask = candidate
|
||||||
|
if mask:
|
||||||
|
payload["mask"] = mask
|
||||||
|
payload["mode"] = "masked"
|
||||||
|
mask_used = True
|
||||||
|
else:
|
||||||
|
# Nothing usable to mask -> whole-image instruction fallback
|
||||||
|
# rather than placing a transparent/empty layer.
|
||||||
|
mode = "instruction"
|
||||||
|
if mode != "masked":
|
||||||
|
# Instruction mode: whole-image edit with no tight mask. Send a
|
||||||
|
# full-white mask so the edit endpoint accepts the request and
|
||||||
|
# regenerates the whole frame under the instruction prompt, which
|
||||||
|
# names what must stay unchanged. This avoids the mask-compositing
|
||||||
|
# halos that loose selections caused.
|
||||||
|
payload["mode"] = "instruction"
|
||||||
|
payload.pop("mask", None)
|
||||||
|
payload["prompt"] = _ensure_keep_clause(payload["prompt"])
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
|
||||||
|
_raw = _data_url_bytes(image)
|
||||||
|
_w, _h = _PILImage.open(io.BytesIO(_raw)).size
|
||||||
|
_mbuf = io.BytesIO()
|
||||||
|
_PILImage.new("L", (_w, _h), 255).save(_mbuf, "PNG")
|
||||||
|
payload["mask"] = "data:image/png;base64," + base64.b64encode(_mbuf.getvalue()).decode()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
place_mode = "masked" if mask_used else "instruction"
|
||||||
|
_t["prep"] = time.perf_counter()
|
||||||
|
try:
|
||||||
|
data = await _post(_AI_OPS[op], payload, owner)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info(
|
||||||
|
"image-op ai_edit_image op=%s stage=provider FAILED image_ms=%.1f prep_ms=%.1f total_ms=%.1f err=%s",
|
||||||
|
op, (_t["image"] - _t["start"]) * 1000.0, (_t["prep"] - _t["image"]) * 1000.0,
|
||||||
|
(time.perf_counter() - _t["start"]) * 1000.0, exc,
|
||||||
|
)
|
||||||
|
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) failed: {exc}", "exit_code": 1}
|
||||||
|
_t["provider"] = time.perf_counter()
|
||||||
|
result = data.get("image") or data.get("data")
|
||||||
|
data_url = _as_data_url(result)
|
||||||
|
if not data_url:
|
||||||
|
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) returned no image.", "exit_code": 1}
|
||||||
|
if _image_is_blank(data_url):
|
||||||
|
return f"ai_edit_image: {op}", {
|
||||||
|
"error": f"ai_edit_image ({op}) produced an empty layer.",
|
||||||
|
"failure_kind": "empty_result",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
info: Dict[str, Any] = {
|
||||||
|
"output": f"Applied AI edit '{op}'" + (" to the current canvas." if used_canvas else "."),
|
||||||
|
"op": op,
|
||||||
|
"model": data.get("model") or model_choice or "",
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||||
|
_t["persist"] = time.perf_counter()
|
||||||
|
if bool(args.get("place", True)):
|
||||||
|
placed = await _place_layer(
|
||||||
|
data_url,
|
||||||
|
f"AI {op}",
|
||||||
|
owner,
|
||||||
|
session,
|
||||||
|
masked=mask_used,
|
||||||
|
blend=str(args.get("blend") or "normal"),
|
||||||
|
opacity=float(args.get("opacity") or 100),
|
||||||
|
mode=place_mode,
|
||||||
|
)
|
||||||
|
info["placed"] = placed
|
||||||
|
if placed:
|
||||||
|
info["output"] += " Placed the result as a new layer."
|
||||||
|
_t["place"] = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
"image-op ai_edit_image op=%s mode=%s canvas=%s image_ms=%.1f mask_ms=%.1f "
|
||||||
|
"model_ms=%.1f persist_ms=%.1f place_ms=%.1f total_ms=%.1f",
|
||||||
|
op, place_mode or "instruction", used_canvas,
|
||||||
|
(_t["image"] - _t["start"]) * 1000.0,
|
||||||
|
(_t["prep"] - _t["image"]) * 1000.0,
|
||||||
|
(_t["provider"] - _t["prep"]) * 1000.0,
|
||||||
|
(_t["persist"] - _t["provider"]) * 1000.0,
|
||||||
|
(_t["place"] - _t["persist"]) * 1000.0,
|
||||||
|
(_t["place"] - _t["start"]) * 1000.0,
|
||||||
|
)
|
||||||
|
return f"ai_edit_image: {op}", info
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateImageLayerTool:
|
||||||
|
"""Generate an image with the configured image model and place it as a layer."""
|
||||||
|
|
||||||
|
async def execute(self, content: str, ctx: dict):
|
||||||
|
args = _parse(content)
|
||||||
|
prompt = str(args.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return "generate_image: invalid", {"error": "generate_image needs a non-empty `prompt`.", "exit_code": 1}
|
||||||
|
owner = ctx.get("owner")
|
||||||
|
if not _provider_available(owner):
|
||||||
|
return "generate_image: provider required", {
|
||||||
|
"error": _provider_message(),
|
||||||
|
"failure_kind": "image_provider_unavailable",
|
||||||
|
"needs_provider": True,
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
session = _session(args, ctx)
|
||||||
|
payload: Dict[str, Any] = {"prompt": prompt}
|
||||||
|
for key in ("model", "size", "quality"):
|
||||||
|
if args.get(key):
|
||||||
|
payload[key] = args[key]
|
||||||
|
try:
|
||||||
|
from src.ai_interaction import do_generate_image
|
||||||
|
|
||||||
|
data = await do_generate_image(
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
session_id=session,
|
||||||
|
owner=owner,
|
||||||
|
incognito=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return "generate_image: error", {"error": f"generate_image failed: {exc}", "exit_code": 1}
|
||||||
|
result = data.get("image_url") or data.get("image") or data.get("data")
|
||||||
|
data_url = _as_data_url(result)
|
||||||
|
if not data_url:
|
||||||
|
return "generate_image", {"error": "generate_image returned no image.", "exit_code": 1}
|
||||||
|
info: Dict[str, Any] = {
|
||||||
|
"output": f"Generated an image for: {prompt[:120]}",
|
||||||
|
"image_size": data.get("image_size"),
|
||||||
|
"model": data.get("image_model") or str(args.get("model") or ""),
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("generate_image persist failed: %s", exc)
|
||||||
|
if bool(args.get("place", True)):
|
||||||
|
placed = await _place_layer(
|
||||||
|
data_url,
|
||||||
|
str(args.get("name") or "Generated image"),
|
||||||
|
owner,
|
||||||
|
session,
|
||||||
|
masked=bool(args.get("mask")),
|
||||||
|
blend=str(args.get("blend") or "normal"),
|
||||||
|
opacity=float(args.get("opacity") or 100),
|
||||||
|
)
|
||||||
|
info["placed"] = placed
|
||||||
|
if placed:
|
||||||
|
info["output"] += " Placed it as a new layer."
|
||||||
|
return "generate_image", info
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""Editor agent/planner model resolution.
|
||||||
|
|
||||||
|
The image-editor Quick Edit planner (`/api/editor/plan`, `/api/editor/agent`)
|
||||||
|
runs on a LOCAL Qwen3.5-9B vision model served on seneca when one is
|
||||||
|
reachable, instead of the hosted chat model. This module owns:
|
||||||
|
|
||||||
|
* the local model base URLs / model id (settings-overridable),
|
||||||
|
* an ordered candidate list (per-request ``agent_model`` override, then the
|
||||||
|
local Qwen3.5-9B, then the hosted default/utility chat model),
|
||||||
|
* a vision fallback chain: local model WITH the canvas screenshot, else a
|
||||||
|
text-only call whose image is described by a separate VL caption model,
|
||||||
|
* a lightweight reachability probe for the configured local bases.
|
||||||
|
|
||||||
|
Everything is best-effort: when no local server is reachable the planner
|
||||||
|
transparently falls back to the previously-used hosted chat model, so the
|
||||||
|
editor agent never regresses to "no plan".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The local Qwen3.5-9B vision server (seneca). :8097 (tmux AGENT_VLM) is the
|
||||||
|
# canonical editor-agent endpoint. Override with the ``editor_agent_base_url``
|
||||||
|
# setting (single URL wins) or the ``ODYSSEUS_EDITOR_AGENT_BASES`` env
|
||||||
|
# (comma-separated). NOTE: :8095 is a DIFFERENT server (Qwen3-VL-30B) used ONLY
|
||||||
|
# for the caption fallback below — never list it here as an agent model.
|
||||||
|
DEFAULT_LOCAL_BASES = [
|
||||||
|
"http://100.69.120.65:8097/v1",
|
||||||
|
]
|
||||||
|
DEFAULT_LOCAL_MODEL = "Qwen/Qwen3.5-9B"
|
||||||
|
|
||||||
|
# Vision caption model used when the editor-agent model cannot accept images.
|
||||||
|
DEFAULT_CAPTION_BASE = "http://100.69.120.65:8095/v1"
|
||||||
|
DEFAULT_CAPTION_MODEL = "Qwen/Qwen3.5-9B"
|
||||||
|
|
||||||
|
# Served-model-id cache per base (populated on first use). The local servers 400
|
||||||
|
# with "Failed to load model" when asked for an id they do not serve (e.g. the
|
||||||
|
# client's default "Qwen3.5-9B-MLX-8bit", or a stale caption model), so we
|
||||||
|
# resolve the requested id against GET {base}/models and fall back to one that
|
||||||
|
# is actually served.
|
||||||
|
_MODELS_CACHE: Dict[str, set] = {}
|
||||||
|
|
||||||
|
_CAPTION_INSTRUCTION = (
|
||||||
|
"Describe this image in one or two sentences for a photo editor: the main "
|
||||||
|
"subject, the background/scene, lighting, dominant colours, and any obvious "
|
||||||
|
"flaws (blown highlights, colour cast, clutter, blur)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _setting(key: str, default: Any = "") -> Any:
|
||||||
|
try:
|
||||||
|
from src.settings import load_settings
|
||||||
|
|
||||||
|
value = load_settings().get(key)
|
||||||
|
return value if value not in (None, "") else default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def local_bases() -> List[str]:
|
||||||
|
override = _setting("editor_agent_base_url")
|
||||||
|
if override:
|
||||||
|
return [str(override)]
|
||||||
|
env = os.getenv("ODYSSEUS_EDITOR_AGENT_BASES")
|
||||||
|
if env:
|
||||||
|
bases = [b.strip() for b in env.split(",") if b.strip()]
|
||||||
|
if bases:
|
||||||
|
return bases
|
||||||
|
return list(DEFAULT_LOCAL_BASES)
|
||||||
|
|
||||||
|
|
||||||
|
def local_model() -> str:
|
||||||
|
return str(_setting("editor_agent_model", DEFAULT_LOCAL_MODEL))
|
||||||
|
|
||||||
|
|
||||||
|
def caption_base() -> str:
|
||||||
|
return str(_setting("editor_caption_base_url", DEFAULT_CAPTION_BASE))
|
||||||
|
|
||||||
|
|
||||||
|
def caption_model() -> str:
|
||||||
|
return str(_setting("editor_caption_model", DEFAULT_CAPTION_MODEL))
|
||||||
|
|
||||||
|
|
||||||
|
# Per-request / per-plan context (contextvars keep this correct under the
|
||||||
|
# async event loop without sharing state between concurrent editor turns).
|
||||||
|
_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
||||||
|
"editor_agent_model_override", default=None
|
||||||
|
)
|
||||||
|
_last_model: contextvars.ContextVar[Optional[Tuple[str, str]]] = contextvars.ContextVar(
|
||||||
|
"editor_agent_last_model", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_override(value: Optional[str]) -> None:
|
||||||
|
_override.set(value or None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_override() -> Optional[str]:
|
||||||
|
return _override.get()
|
||||||
|
|
||||||
|
|
||||||
|
def set_last_model(model: str, source: str) -> None:
|
||||||
|
_last_model.set((model, source))
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_model() -> Optional[Tuple[str, str]]:
|
||||||
|
return _last_model.get()
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_url(base: str) -> str:
|
||||||
|
b = str(base or "").rstrip("/")
|
||||||
|
if b.endswith("/chat/completions"):
|
||||||
|
return b
|
||||||
|
return b + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_override(override: str) -> Tuple[str, str]:
|
||||||
|
"""Parse ``base::model`` (or a bare model id)."""
|
||||||
|
raw = str(override or "").strip()
|
||||||
|
if "::" in raw:
|
||||||
|
base, model = raw.split("::", 1)
|
||||||
|
return base.strip(), model.strip()
|
||||||
|
return "", raw
|
||||||
|
|
||||||
|
|
||||||
|
def _has_image(messages: List[dict]) -> bool:
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _first_image(messages: List[dict]) -> Optional[str]:
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||||
|
url = (part.get("image_url") or {}).get("url")
|
||||||
|
if isinstance(url, str):
|
||||||
|
return url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_images(messages: List[dict]) -> List[dict]:
|
||||||
|
out: List[dict] = []
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
text = "\n".join(
|
||||||
|
str(part.get("text") or "")
|
||||||
|
for part in content
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text"
|
||||||
|
)
|
||||||
|
out.append({"role": msg.get("role", "user"), "content": text})
|
||||||
|
else:
|
||||||
|
out.append({"role": msg.get("role", "user"), "content": content})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _with_caption(messages: List[dict], caption: str) -> List[dict]:
|
||||||
|
note = f"\n\n[Canvas screenshot caption: {caption}]"
|
||||||
|
out = [dict(m) for m in messages]
|
||||||
|
for msg in reversed(out):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
msg["content"] = str(msg.get("content") or "") + note
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_chat(
|
||||||
|
base: str,
|
||||||
|
model: str,
|
||||||
|
messages: List[dict],
|
||||||
|
headers: Optional[dict],
|
||||||
|
timeout: float,
|
||||||
|
max_tokens: int = 1024,
|
||||||
|
response_format: Optional[dict] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if not base or not model:
|
||||||
|
return None
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
# Constrain the planner to a strict JSON ops schema (structured outputs /
|
||||||
|
# grammar) so the model can only emit valid editor ops. Unknown servers
|
||||||
|
# ignore the field; the server-side schema validation is the real enforcer.
|
||||||
|
if isinstance(response_format, dict):
|
||||||
|
payload["response_format"] = response_format
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.post(_chat_url(base), json=payload, headers=headers or {})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.debug(
|
||||||
|
"editor agent chat %s -> %s (%s)", base, resp.status_code, resp.text[:200]
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("editor agent chat %s failed: %s", base, exc)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _served_models(base: str, timeout: float = 3.0) -> set:
|
||||||
|
"""Return the set of model ids a base serves (cached; empty on failure)."""
|
||||||
|
if not base:
|
||||||
|
return set()
|
||||||
|
cached = _MODELS_CACHE.get(base)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
ids: set = set()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.get(base.rstrip("/") + "/models")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
ids = {
|
||||||
|
str(m.get("id"))
|
||||||
|
for m in (resp.json().get("data") or [])
|
||||||
|
if isinstance(m, dict) and m.get("id")
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
ids = set()
|
||||||
|
_MODELS_CACHE[base] = ids
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_model(base: str, requested: str, fallback: str) -> str:
|
||||||
|
"""Return a model id ``base`` actually serves.
|
||||||
|
|
||||||
|
If the requested id is served, keep it; otherwise use ``fallback`` (when
|
||||||
|
served) or any served id. When the served list is unknown (server down),
|
||||||
|
keep the requested id (or ``fallback``).
|
||||||
|
"""
|
||||||
|
requested = str(requested or "").strip()
|
||||||
|
fallback = str(fallback or "").strip()
|
||||||
|
ids = await _served_models(base)
|
||||||
|
if ids:
|
||||||
|
if requested and requested in ids:
|
||||||
|
return requested
|
||||||
|
if fallback in ids:
|
||||||
|
return fallback
|
||||||
|
return sorted(ids)[0]
|
||||||
|
return requested or fallback
|
||||||
|
|
||||||
|
|
||||||
|
async def caption_image(data_url: str, owner: Optional[str] = None, timeout: float = 90.0) -> Optional[str]:
|
||||||
|
if not isinstance(data_url, str) or not data_url.startswith("data:image/"):
|
||||||
|
return None
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": _CAPTION_INSTRUCTION},
|
||||||
|
{"type": "image_url", "image_url": {"url": data_url}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
base = caption_base()
|
||||||
|
model = await _resolve_model(base, caption_model(), local_model())
|
||||||
|
return await _post_chat(base, model, messages, None, timeout, max_tokens=200)
|
||||||
|
|
||||||
|
|
||||||
|
_DONE_RE = re.compile(r"^\s*DONE\b", re.I)
|
||||||
|
_RETRY_RE = re.compile(r"RETRY:?\s*(.+)", re.I | re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_self_check(text: str) -> Dict[str, Any]:
|
||||||
|
s = str(text or "").strip()
|
||||||
|
if _DONE_RE.match(s):
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "", "text": s}
|
||||||
|
m = _RETRY_RE.search(s)
|
||||||
|
if m or re.search(r"\bretry\b", s, re.I):
|
||||||
|
hint = (m.group(1).strip() if m else s) or s
|
||||||
|
return {"done": False, "issues": [hint], "retry_hint": hint, "text": s}
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "", "text": s}
|
||||||
|
|
||||||
|
|
||||||
|
async def self_check(
|
||||||
|
before: Any,
|
||||||
|
after: Any,
|
||||||
|
prompt: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Same-origin VL self-check: compare BEFORE vs AFTER against the intent.
|
||||||
|
|
||||||
|
The browser cannot call the local VL server directly (it sends no CORS
|
||||||
|
headers), so ``/api/editor/self-check`` proxies here. Returns
|
||||||
|
``{done, issues, retry_hint, text}``; on any failure (bad input, the model
|
||||||
|
is unreachable, or a timeout) it degrades to ``done=True`` with
|
||||||
|
``reason="unavailable"`` so the client never gets stuck retrying.
|
||||||
|
"""
|
||||||
|
if not (
|
||||||
|
isinstance(before, str) and before.startswith("data:image/")
|
||||||
|
and isinstance(after, str) and after.startswith("data:image/")
|
||||||
|
):
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "",
|
||||||
|
"reason": "unavailable", "text": "DONE"}
|
||||||
|
bases = local_bases() or list(DEFAULT_LOCAL_BASES)
|
||||||
|
base = bases[0]
|
||||||
|
mdl = await _resolve_model(base, str(model or local_model()), local_model())
|
||||||
|
messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": str(prompt or "")},
|
||||||
|
{"type": "image_url", "image_url": {"url": before}},
|
||||||
|
{"type": "image_url", "image_url": {"url": after}},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
text = await _post_chat(base, mdl, messages, None, timeout, max_tokens=140)
|
||||||
|
if not text:
|
||||||
|
return {"done": True, "issues": [], "retry_hint": "",
|
||||||
|
"reason": "unavailable", "text": "DONE"}
|
||||||
|
out = _parse_self_check(text)
|
||||||
|
out["model"] = mdl
|
||||||
|
out["source"] = "local"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||||
|
cands: List[Dict[str, Any]] = []
|
||||||
|
if override:
|
||||||
|
base, model = _split_override(override)
|
||||||
|
if model:
|
||||||
|
cands.append(
|
||||||
|
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
|
||||||
|
)
|
||||||
|
for base in local_bases():
|
||||||
|
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
|
||||||
|
for prefix in ("utility", "default"):
|
||||||
|
try:
|
||||||
|
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if url and model:
|
||||||
|
cands.append({"base": url, "model": model, "headers": headers, "source": "hosted"})
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return cands
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_with_editor_agent(
|
||||||
|
messages: List[dict],
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
override: Optional[str] = None,
|
||||||
|
timeout: float = 90.0,
|
||||||
|
response_format: Optional[dict] = None,
|
||||||
|
) -> Optional[Tuple[str, str, str]]:
|
||||||
|
"""Return (raw_plan_text, model_used, source) or None.
|
||||||
|
|
||||||
|
Tries the local model WITH the screenshot, then the same model text-only
|
||||||
|
with a VL caption of the screenshot, then the hosted chat model.
|
||||||
|
|
||||||
|
``response_format`` (a JSON-schema structured-output constraint) is sent
|
||||||
|
ONLY to the local editor-agent bases, so a hosted fallback model that does
|
||||||
|
not support the field never rejects the request.
|
||||||
|
"""
|
||||||
|
has_img = _has_image(messages)
|
||||||
|
text_messages = _strip_images(messages) if has_img else messages
|
||||||
|
image = _first_image(messages) if has_img else None
|
||||||
|
caption: Optional[str] = None
|
||||||
|
_local = {str(b).rstrip("/") for b in local_bases()}
|
||||||
|
for cand in _candidates(owner, override):
|
||||||
|
base, model = cand["base"], cand["model"]
|
||||||
|
headers = cand.get("headers")
|
||||||
|
is_local = cand["source"] == "local" or str(base).rstrip("/") in _local
|
||||||
|
rf = response_format if (response_format and is_local) else None
|
||||||
|
extra = {"response_format": rf} if rf else {}
|
||||||
|
if has_img:
|
||||||
|
raw = await _post_chat(base, model, messages, headers, timeout, **extra)
|
||||||
|
if raw:
|
||||||
|
return raw, model, cand["source"]
|
||||||
|
if caption is None and image:
|
||||||
|
caption = await caption_image(image, owner)
|
||||||
|
if caption:
|
||||||
|
raw = await _post_chat(
|
||||||
|
base, model, _with_caption(text_messages, caption), headers, timeout, **extra,
|
||||||
|
)
|
||||||
|
if raw:
|
||||||
|
return raw, model, cand["source"] + "+caption"
|
||||||
|
else:
|
||||||
|
raw = await _post_chat(
|
||||||
|
base, model, text_messages, headers, timeout, **extra
|
||||||
|
)
|
||||||
|
if raw:
|
||||||
|
return raw, model, cand["source"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Models available to the editor agent, for the model picker.
|
||||||
|
|
||||||
|
Lists the local Qwen3.5-9B (one entry per reachable base) plus the hosted
|
||||||
|
default/utility chat model that the planner falls back to.
|
||||||
|
"""
|
||||||
|
models: List[Dict[str, Any]] = []
|
||||||
|
seen: set = set()
|
||||||
|
for base in local_bases():
|
||||||
|
model = local_model()
|
||||||
|
key = ("local", base, model)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
models.append({
|
||||||
|
"id": model,
|
||||||
|
"label": f"{model} · local",
|
||||||
|
"source": "local",
|
||||||
|
"vision": True,
|
||||||
|
"base_url": base,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
|
||||||
|
for prefix in ("utility", "default"):
|
||||||
|
try:
|
||||||
|
url, model, _headers = resolve_endpoint(prefix, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if url and model:
|
||||||
|
key = ("hosted", model)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
models.append({
|
||||||
|
"id": model,
|
||||||
|
"label": model,
|
||||||
|
"source": "hosted",
|
||||||
|
"vision": None,
|
||||||
|
"base_url": url,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reachability probe (best effort, non-blocking).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PROBE: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def probe_models(base: str, timeout: float = 3.0) -> bool:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
resp = client.get(str(base).rstrip("/") + "/models")
|
||||||
|
return resp.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_worker() -> None:
|
||||||
|
for base in local_bases():
|
||||||
|
ok = probe_models(base)
|
||||||
|
_PROBE[base] = ok
|
||||||
|
logger.info("editor agent local base %s reachable=%s", base, ok)
|
||||||
|
cap = caption_base()
|
||||||
|
_PROBE[cap] = probe_models(cap)
|
||||||
|
logger.info("editor agent caption base %s reachable=%s", cap, _PROBE[cap])
|
||||||
|
|
||||||
|
|
||||||
|
def startup_probe() -> None:
|
||||||
|
"""Kick off a background reachability probe (never blocks startup)."""
|
||||||
|
try:
|
||||||
|
threading.Thread(target=_probe_worker, name="editor-agent-probe", daemon=True).start()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("editor agent probe thread failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_status() -> Dict[str, bool]:
|
||||||
|
return dict(_PROBE)
|
||||||
File diff suppressed because it is too large
Load Diff
+42
-23
@@ -68,6 +68,7 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
"uid": str(message.get("uid") or _uid(index)),
|
"uid": str(message.get("uid") or _uid(index)),
|
||||||
|
"message_id": str(message.get("message_id") or ""),
|
||||||
"account": str(message.get("account") or "fixture"),
|
"account": str(message.get("account") or "fixture"),
|
||||||
"account_email": str(message.get("account_email") or message.get("to") or ""),
|
"account_email": str(message.get("account_email") or message.get("to") or ""),
|
||||||
"account_id": str(message.get("account_id") or "fixture"),
|
"account_id": str(message.get("account_id") or "fixture"),
|
||||||
@@ -78,6 +79,16 @@ def _row(index: int, message: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"folder": str(message.get("folder") or "INBOX"),
|
"folder": str(message.get("folder") or "INBOX"),
|
||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
"has_attachments": bool(attachments),
|
"has_attachments": bool(attachments),
|
||||||
|
"summary": str(message.get("summary") or message.get("body") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _account_matches(message: dict[str, Any], account: str | None) -> bool:
|
||||||
|
selector = str(account or "").strip().casefold()
|
||||||
|
return not selector or selector in {
|
||||||
|
str(message.get("account") or "fixture").strip().casefold(),
|
||||||
|
str(message.get("account_email") or message.get("to") or "").strip().casefold(),
|
||||||
|
str(message.get("account_id") or "fixture").strip().casefold(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -108,9 +119,16 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
|
|
||||||
bare = tool.removeprefix("mcp__email__")
|
bare = tool.removeprefix("mcp__email__")
|
||||||
if bare == "list_email_accounts":
|
if bare == "list_email_accounts":
|
||||||
|
accounts = {}
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
row = _row(index, message)
|
||||||
|
identity = (row["account_id"], row["account"], row["account_email"])
|
||||||
|
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
|
||||||
|
"email": row["account_email"], "default": not accounts})
|
||||||
return {
|
return {
|
||||||
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
"accounts": list(accounts.values()),
|
||||||
"output": "Fixture mailbox (default)",
|
"output": "\n".join(f"{row['name']} <{row['email']}> ({row['id']})" for row in accounts.values())
|
||||||
|
or "No fixture email accounts found.",
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,14 +139,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
rows = [
|
rows = [
|
||||||
_row(i, m) for i, m in enumerate(messages)
|
_row(i, m) for i, m in enumerate(messages)
|
||||||
if _matches(m, query) and _folder_matches(m.get("folder"), folder)
|
if _matches(m, query) and _folder_matches(m.get("folder"), folder)
|
||||||
and (
|
and _account_matches(m, account)
|
||||||
not account
|
|
||||||
or account in {
|
|
||||||
str(m.get("account") or "").strip().casefold(),
|
|
||||||
str(m.get("account_email") or "").strip().casefold(),
|
|
||||||
str(m.get("account_id") or "").strip().casefold(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
limit = args.get("max_results", args.get("limit", 20))
|
limit = args.get("max_results", args.get("limit", 20))
|
||||||
try:
|
try:
|
||||||
@@ -139,11 +150,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
return {"output": "No emails found.", "emails": [], "exit_code": 0}
|
return {"output": "No emails found.", "emails": [], "exit_code": 0}
|
||||||
output_lines = [f"Found {len(rows)} email(s):", ""]
|
output_lines = [f"Found {len(rows)} email(s):", ""]
|
||||||
for index, r in enumerate(rows, start=1):
|
for index, r in enumerate(rows, start=1):
|
||||||
source = next(
|
summary = r["summary"].strip()
|
||||||
(m for i, m in enumerate(messages) if _uid(i) == r["uid"]),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
summary = str(source.get("summary") or source.get("body") or "").strip()
|
|
||||||
output_lines.extend([
|
output_lines.extend([
|
||||||
f"{index}. **{r['subject']}**",
|
f"{index}. **{r['subject']}**",
|
||||||
f" From: {r['from']}",
|
f" From: {r['from']}",
|
||||||
@@ -159,15 +166,19 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
|
|
||||||
if bare == "read_email":
|
if bare == "read_email":
|
||||||
uid = str(args.get("uid") or "")
|
uid = str(args.get("uid") or "")
|
||||||
|
message_id = str(args.get("message_id") or "")
|
||||||
folder = str(args.get("folder") or "INBOX")
|
folder = str(args.get("folder") or "INBOX")
|
||||||
try:
|
if not uid and not message_id:
|
||||||
index = int(uid) - 1
|
return {"error": "read_email requires a UID or Message-ID.", "exit_code": 1}
|
||||||
except (TypeError, ValueError):
|
matches = [(m, _row(i, m)) for i, m in enumerate(messages)
|
||||||
index = -1
|
if _folder_matches(m.get("folder"), folder) and _account_matches(m, args.get("account"))
|
||||||
if index < 0 or index >= len(messages) or not _folder_matches(messages[index].get("folder"), folder):
|
and (not uid or str(m.get("uid") or _uid(i)) == uid)
|
||||||
return {"error": f"Email UID {uid} not found.", "exit_code": 1}
|
and (not message_id or str(m.get("message_id") or "") == message_id)]
|
||||||
message = messages[index]
|
if not matches:
|
||||||
row = _row(index, message)
|
return {"error": f"Email UID/Message-ID {uid or message_id} not found.", "exit_code": 1}
|
||||||
|
if len(matches) != 1:
|
||||||
|
return {"error": "Email identity is ambiguous; specify its account and folder.", "exit_code": 1}
|
||||||
|
message, row = matches[0]
|
||||||
output = (
|
output = (
|
||||||
f"UID: {row['uid']}\nSubject: {row['subject']}\nFrom: {row['from']}\n"
|
f"UID: {row['uid']}\nSubject: {row['subject']}\nFrom: {row['from']}\n"
|
||||||
f"Date: {row['date']}\n\n{message.get('body') or ''}"
|
f"Date: {row['date']}\n\n{message.get('body') or ''}"
|
||||||
@@ -185,6 +196,7 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
all_messages = _messages()
|
all_messages = _messages()
|
||||||
owner_value = str(owner or "").strip()
|
owner_value = str(owner or "").strip()
|
||||||
visible_index = -1
|
visible_index = -1
|
||||||
|
matches = []
|
||||||
for original in all_messages:
|
for original in all_messages:
|
||||||
if owner_value and original.get("owner") and original.get("owner") != owner_value:
|
if owner_value and original.get("owner") and original.get("owner") != owner_value:
|
||||||
continue
|
continue
|
||||||
@@ -193,6 +205,13 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
continue
|
continue
|
||||||
if not _folder_matches(original.get("folder"), folder):
|
if not _folder_matches(original.get("folder"), folder):
|
||||||
continue
|
continue
|
||||||
|
if not _account_matches(original, args.get("account")):
|
||||||
|
continue
|
||||||
|
matches.append(original)
|
||||||
|
if len(matches) > 1:
|
||||||
|
return {"error": "Email identity is ambiguous; specify its account and folder.", "exit_code": 1}
|
||||||
|
if matches:
|
||||||
|
original = matches[0]
|
||||||
if bare == "archive_email":
|
if bare == "archive_email":
|
||||||
original["folder"] = "Archive"
|
original["folder"] = "Archive"
|
||||||
action = "Archived"
|
action = "Archived"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Process-wide shutdown signalling for graceful restarts.
|
||||||
|
|
||||||
|
Server-Sent-Events / streaming responses never complete on their own, so
|
||||||
|
uvicorn's graceful shutdown would wait for an open browser stream up to the
|
||||||
|
full ``timeout_graceful_shutdown`` window before exiting. A SIGTERM/SIGINT
|
||||||
|
handler flips this flag and tells uvicorn to exit; every streaming generator
|
||||||
|
checks the flag on each loop and ends promptly, so a restart completes in
|
||||||
|
seconds while ordinary in-flight requests still finish.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SHUTTING_DOWN = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def is_shutting_down() -> bool:
|
||||||
|
"""True once a shutdown (SIGTERM/SIGINT) has been requested."""
|
||||||
|
return _SHUTTING_DOWN.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
def request_shutdown() -> None:
|
||||||
|
"""Mark the process as shutting down (idempotent)."""
|
||||||
|
if not _SHUTTING_DOWN.is_set():
|
||||||
|
_SHUTTING_DOWN.set()
|
||||||
|
logger.info("shutdown requested; streaming responses will end promptly")
|
||||||
|
|
||||||
|
|
||||||
|
def reset() -> None:
|
||||||
|
"""Clear the flag (for tests)."""
|
||||||
|
_SHUTTING_DOWN.clear()
|
||||||
+13
-3
@@ -16,7 +16,7 @@ from typing import Dict, Optional
|
|||||||
from src.tools._common import _parse_tool_args
|
from src.tools._common import _parse_tool_args
|
||||||
|
|
||||||
|
|
||||||
async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
async def do_edit_image(content: str, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||||
"""Create an owner-scoped edited copy of a gallery image."""
|
"""Create an owner-scoped edited copy of a gallery image."""
|
||||||
try:
|
try:
|
||||||
args = _parse_tool_args(content)
|
args = _parse_tool_args(content)
|
||||||
@@ -45,7 +45,7 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
if not load_settings().get('image_gen_enabled', True):
|
if not load_settings().get('image_gen_enabled', True):
|
||||||
return {'error': 'Image generation is disabled by the administrator.', 'exit_code': 1}
|
return {'error': 'Image generation is disabled by the administrator.', 'exit_code': 1}
|
||||||
from src.ai_interaction import do_edit_image as edit_with_model
|
from src.ai_interaction import do_edit_image as edit_with_model
|
||||||
return await edit_with_model(str(args.get('prompt') or '').strip(), info['path'], owner=owner, size='auto')
|
return await edit_with_model(str(args.get('prompt') or '').strip(), info['path'], owner=owner, size='auto', incognito=incognito)
|
||||||
|
|
||||||
from core.database import GalleryImage, SessionLocal
|
from core.database import GalleryImage, SessionLocal
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
@@ -79,7 +79,7 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
session_id = source.session_id
|
session_id = source.session_id
|
||||||
db.close()
|
db.close()
|
||||||
from src.ai_interaction import do_edit_image as edit_with_model
|
from src.ai_interaction import do_edit_image as edit_with_model
|
||||||
return await edit_with_model(prompt, str(source_path), session_id=session_id, owner=owner, size='auto')
|
return await edit_with_model(prompt, str(source_path), session_id=session_id, owner=owner, size='auto', incognito=incognito)
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
@@ -111,6 +111,16 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
output_bytes = output.getvalue()
|
output_bytes = output.getvalue()
|
||||||
width, height = image.size
|
width, height = image.size
|
||||||
|
|
||||||
|
if incognito:
|
||||||
|
import base64
|
||||||
|
return {
|
||||||
|
'output': f'Image edited ({action}). Private result, not saved to gallery.',
|
||||||
|
'exit_code': 0, 'image_id': None,
|
||||||
|
'image_url': 'data:image/png;base64,' + base64.b64encode(output_bytes).decode('ascii'),
|
||||||
|
'image_prompt': source.prompt or action, 'image_model': f'edit_image:{action}',
|
||||||
|
'image_size': f'{width}x{height}', 'image_quality': source.quality or '',
|
||||||
|
}
|
||||||
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
(root / filename).write_bytes(output_bytes)
|
(root / filename).write_bytes(output_bytes)
|
||||||
|
|||||||
+9
-70
@@ -63,80 +63,19 @@ INTERNAL_UPLOAD_URL_RE = re.compile(
|
|||||||
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
|
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
|
||||||
r"(?=$|[\s\"'<>\[\](){},;!?:&#]|\.(?![A-Za-z0-9]))"
|
r"(?=$|[\s\"'<>\[\](){},;!?:&#]|\.(?![A-Za-z0-9]))"
|
||||||
)
|
)
|
||||||
# `<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id=["'](id)["'][^>]*-->` and
|
PDF_SOURCE_UPLOAD_RE = re.compile(
|
||||||
# `\[Attachment:[^\]\r\n]*\|\s*id=(id)(?:\s*\||\s*\])` are matched by the
|
r"<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id="
|
||||||
# forward-only scanners below. As single regexes, every opener in a run with no
|
r"[\"']([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)[\"'][^>]*-->",
|
||||||
# closing `>` / `]` rescanned to the end of that run: O(n^2) on chat content,
|
|
||||||
# which is unbounded on persisted assistant output (CodeQL py/polynomial-redos).
|
|
||||||
_PDF_SOURCE_OPEN_RE = re.compile(r"<!--\s*pdf(?:_form)?_source\b", re.IGNORECASE)
|
|
||||||
_PDF_SOURCE_ID_RE = re.compile(
|
|
||||||
r"\bupload_id=[\"']([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)[\"']",
|
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_ATTACHMENT_REFERENCE_OPEN_RE = re.compile(r"\[Attachment:", re.IGNORECASE)
|
ATTACHMENT_REFERENCE_LINE_RE = re.compile(
|
||||||
_ATTACHMENT_REFERENCE_STOP_RE = re.compile(r"[\]\r\n]")
|
r"\[Attachment:[^\]\r\n]*\|\s*id="
|
||||||
_ATTACHMENT_REFERENCE_TAIL_RE = re.compile(
|
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
|
||||||
r"\|\s*id=([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)(?:\s*\||\s*\])",
|
r"(?:\s*\||\s*\])",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _pdf_source_upload_ids(value: str) -> list[str]:
|
|
||||||
"""IDs from `<!-- pdf_source ... upload_id="<id>" ... -->` comments.
|
|
||||||
|
|
||||||
Every opener before the next `>` shares that `>`: the comment matches only
|
|
||||||
if `--` sits right before it, and then with the last `upload_id=` (the
|
|
||||||
greedy `[^>]*` backtracks from the right). Otherwise all of those openers
|
|
||||||
fail together, so the scan resumes after the `>`.
|
|
||||||
"""
|
|
||||||
found: list[str] = []
|
|
||||||
pos = 0
|
|
||||||
while True:
|
|
||||||
opener = _PDF_SOURCE_OPEN_RE.search(value, pos)
|
|
||||||
if opener is None:
|
|
||||||
return found
|
|
||||||
close = value.find(">", opener.end())
|
|
||||||
if close < 0:
|
|
||||||
return found
|
|
||||||
if value[close - 2:close] == "--":
|
|
||||||
last = None
|
|
||||||
for last in _PDF_SOURCE_ID_RE.finditer(value, opener.end(), close):
|
|
||||||
pass
|
|
||||||
if last is not None:
|
|
||||||
found.append(last.group(1))
|
|
||||||
pos = close + 1
|
|
||||||
|
|
||||||
|
|
||||||
def _attachment_reference_ids(value: str) -> list[str]:
|
|
||||||
"""IDs from `[Attachment: name | id=<id> | ...]` reference lines.
|
|
||||||
|
|
||||||
The label scan stops at the first `]`/CR/LF, so every opener before that
|
|
||||||
stop shares it and can only use the last `|` (scanning right to left)
|
|
||||||
whose tail matches. If none does, all of those openers fail together and
|
|
||||||
the scan resumes at the stop.
|
|
||||||
"""
|
|
||||||
found: list[str] = []
|
|
||||||
pos = 0
|
|
||||||
while True:
|
|
||||||
opener = _ATTACHMENT_REFERENCE_OPEN_RE.search(value, pos)
|
|
||||||
if opener is None:
|
|
||||||
return found
|
|
||||||
stop = _ATTACHMENT_REFERENCE_STOP_RE.search(value, opener.end())
|
|
||||||
end = stop.start() if stop else len(value)
|
|
||||||
tail = None
|
|
||||||
pipe = value.rfind("|", opener.end(), end)
|
|
||||||
while pipe >= 0:
|
|
||||||
tail = _ATTACHMENT_REFERENCE_TAIL_RE.match(value, pipe)
|
|
||||||
if tail is not None:
|
|
||||||
break
|
|
||||||
pipe = value.rfind("|", opener.end(), pipe)
|
|
||||||
if tail is None:
|
|
||||||
pos = end
|
|
||||||
else:
|
|
||||||
found.append(tail.group(1))
|
|
||||||
pos = tail.end()
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_upload_id(upload_id: str) -> bool:
|
def is_valid_upload_id(upload_id: str) -> bool:
|
||||||
"""Return True when *upload_id* matches the canonical uploads.json id format."""
|
"""Return True when *upload_id* matches the canonical uploads.json id format."""
|
||||||
return UPLOAD_ID_RE.fullmatch(upload_id or "") is not None
|
return UPLOAD_ID_RE.fullmatch(upload_id or "") is not None
|
||||||
@@ -171,8 +110,8 @@ def extract_internal_upload_ids(value: Any) -> set[str]:
|
|||||||
return set()
|
return set()
|
||||||
return (
|
return (
|
||||||
set(INTERNAL_UPLOAD_URL_RE.findall(value))
|
set(INTERNAL_UPLOAD_URL_RE.findall(value))
|
||||||
| set(_pdf_source_upload_ids(value))
|
| set(PDF_SOURCE_UPLOAD_RE.findall(value))
|
||||||
| set(_attachment_reference_ids(value))
|
| set(ATTACHMENT_REFERENCE_LINE_RE.findall(value))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Tests for src/activity.py — in-flight heavy-image-op counters (safe restart)."""
|
||||||
|
from src import activity
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_op_counter_increments_and_decrements():
|
||||||
|
assert activity.heavy_image_ops_active() == 0
|
||||||
|
activity.begin_image_op("a")
|
||||||
|
activity.begin_image_op("a")
|
||||||
|
assert activity.heavy_image_ops_active() == 2
|
||||||
|
activity.end_image_op("a")
|
||||||
|
assert activity.heavy_image_ops_active() == 1
|
||||||
|
activity.end_image_op("a")
|
||||||
|
assert activity.heavy_image_ops_active() == 0
|
||||||
|
# never goes negative
|
||||||
|
activity.end_image_op("a")
|
||||||
|
assert activity.heavy_image_ops_active() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_op_context_manager_and_busy():
|
||||||
|
assert activity.busy() is False
|
||||||
|
with activity.image_op("/api/image/inpaint"):
|
||||||
|
assert activity.busy() is True
|
||||||
|
snap = activity.activity()
|
||||||
|
assert snap["image_ops"] == 1
|
||||||
|
assert snap["image_ops_by_name"].get("/api/image/inpaint") == 1
|
||||||
|
assert activity.busy() is False
|
||||||
|
assert activity.activity()["image_ops"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_op_released_on_exception():
|
||||||
|
try:
|
||||||
|
with activity.image_op("boom"):
|
||||||
|
raise RuntimeError("x")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
assert activity.heavy_image_ops_active() == 0
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
|||||||
"""Repository asset ownership guards for issues #1335 and #6175.
|
"""Repository asset ownership guards for issues #1335 and #6175.
|
||||||
|
|
||||||
Public Markdown and landing-page media belong in website/, while shared
|
Public Markdown and landing-page media belong in website/, while shared
|
||||||
optional README/packaging imagery belongs in assets/branding/. Retained images
|
README/packaging imagery belongs in assets/branding/. Images in either managed
|
||||||
in either managed root must be referenced by tracked text, and every retained
|
root must be referenced by tracked text, and every tracked website video must
|
||||||
website video must be referenced by the site's entry point. Publication can
|
be referenced by the site's entry point.
|
||||||
operate with no imagery or videos.
|
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -42,8 +41,7 @@ def _tracked(*paths_under):
|
|||||||
return None
|
return None
|
||||||
if out.returncode != 0:
|
if out.returncode != 0:
|
||||||
return None
|
return None
|
||||||
return [REPO / line for line in out.stdout.splitlines()
|
return [REPO / line for line in out.stdout.splitlines() if line.strip()]
|
||||||
if line.strip() and (REPO / line).is_file()]
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_orphan_documentation_or_branding_images():
|
def test_no_orphan_documentation_or_branding_images():
|
||||||
@@ -51,6 +49,9 @@ def test_no_orphan_documentation_or_branding_images():
|
|||||||
if managed_files is None:
|
if managed_files is None:
|
||||||
pytest.skip("not a git checkout")
|
pytest.skip("not a git checkout")
|
||||||
managed_images = [p for p in managed_files if p.suffix.lower() in IMAGE_EXTS]
|
managed_images = [p for p in managed_files if p.suffix.lower() in IMAGE_EXTS]
|
||||||
|
assert any("assets/branding" in p.as_posix() for p in managed_images), (
|
||||||
|
"expected assets/branding/ to contain the shared project imagery"
|
||||||
|
)
|
||||||
|
|
||||||
# All tracked text we might reference an image from.
|
# All tracked text we might reference an image from.
|
||||||
all_tracked = _tracked(".") or []
|
all_tracked = _tracked(".") or []
|
||||||
@@ -93,6 +94,7 @@ def test_pages_site_owns_its_entrypoint_and_media():
|
|||||||
assert text.startswith("---\nlayout: default\n---\n"), guide
|
assert text.startswith("---\nlayout: default\n---\n"), guide
|
||||||
|
|
||||||
website_videos = [p for p in website_files if p.suffix.lower() in VIDEO_EXTS]
|
website_videos = [p for p in website_files if p.suffix.lower() in VIDEO_EXTS]
|
||||||
|
assert website_videos, "expected website/ to contain the landing-page videos"
|
||||||
|
|
||||||
entrypoint = (REPO / "website/index.html").read_text(encoding="utf-8")
|
entrypoint = (REPO / "website/index.html").read_text(encoding="utf-8")
|
||||||
unreferenced = [
|
unreferenced = [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.tools.image import do_edit_image
|
from src.tools.image import do_edit_image
|
||||||
@@ -122,7 +123,8 @@ def test_edit_image_rejects_actions_without_complete_input_contract():
|
|||||||
assert "Use prompt, upscale or rembg" in result["error"]
|
assert "Use prompt, upscale or rembg" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path):
|
@pytest.mark.parametrize('incognito', [False, True])
|
||||||
|
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path, incognito):
|
||||||
source_path = tmp_path / 'source.png'
|
source_path = tmp_path / 'source.png'
|
||||||
Image.new('RGB', (3, 2), 'red').save(source_path)
|
Image.new('RGB', (3, 2), 'red').save(source_path)
|
||||||
original = source_path.read_bytes()
|
original = source_path.read_bytes()
|
||||||
@@ -135,9 +137,9 @@ def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, t
|
|||||||
return {'image_id': 'edited', 'image_url': '/api/generated-image/edited.png'}
|
return {'image_id': 'edited', 'image_url': '/api/generated-image/edited.png'}
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
|
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
|
||||||
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice'))
|
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice', incognito=incognito))
|
||||||
assert result['image_id'] == 'edited'
|
assert result['image_id'] == 'edited'
|
||||||
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto'})]
|
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto', 'incognito': incognito})]
|
||||||
assert source_path.read_bytes() == original
|
assert source_path.read_bytes() == original
|
||||||
denied = asyncio.run(do_edit_image('{"image_id":"source-id","action":"prompt","prompt":"edit"}', owner=None))
|
denied = asyncio.run(do_edit_image('{"image_id":"source-id","action":"prompt","prompt":"edit"}', owner=None))
|
||||||
assert denied['error'] == 'Image not found'
|
assert denied['error'] == 'Image not found'
|
||||||
@@ -165,7 +167,7 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
|
|||||||
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
|
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
|
||||||
args = '{"image_id":"odysseus://attachment/upload.jpg","action":"prompt","prompt":"Make more realistic"}'
|
args = '{"image_id":"odysseus://attachment/upload.jpg","action":"prompt","prompt":"Make more realistic"}'
|
||||||
assert asyncio.run(do_edit_image(args, owner='alice')) == {'image_id': 'edited'}
|
assert asyncio.run(do_edit_image(args, owner='alice')) == {'image_id': 'edited'}
|
||||||
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto'})]
|
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto', 'incognito': False})]
|
||||||
assert resolutions == [('upload.jpg', {'owner': 'alice', 'allow_admin': False})]
|
assert resolutions == [('upload.jpg', {'owner': 'alice', 'allow_admin': False})]
|
||||||
assert 'error' in asyncio.run(do_edit_image(args, owner='bob'))
|
assert 'error' in asyncio.run(do_edit_image(args, owner='bob'))
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
@@ -173,3 +175,23 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
|
|||||||
disabled = asyncio.run(do_edit_image(args, owner='alice'))
|
disabled = asyncio.run(do_edit_image(args, owner='alice'))
|
||||||
assert 'disabled' in disabled['error']
|
assert 'disabled' in disabled['error']
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_upscale_does_not_save_image_or_gallery_record(monkeypatch, tmp_path):
|
||||||
|
source_path = tmp_path / 'source.png'
|
||||||
|
Image.new('RGB', (3, 2), 'red').save(source_path)
|
||||||
|
original = source_path.read_bytes()
|
||||||
|
db = _Db(_source(source_path.name))
|
||||||
|
monkeypatch.setattr('core.database.SessionLocal', lambda: db)
|
||||||
|
monkeypatch.setattr('src.constants.GENERATED_IMAGES_DIR', str(tmp_path))
|
||||||
|
result = asyncio.run(do_edit_image(
|
||||||
|
'{"image_id":"source-id","action":"upscale","scale":2}',
|
||||||
|
owner='alice', incognito=True,
|
||||||
|
))
|
||||||
|
assert result['exit_code'] == 0
|
||||||
|
assert result['image_url'].startswith('data:image/png;base64,')
|
||||||
|
assert not result['image_id']
|
||||||
|
assert db.added == []
|
||||||
|
assert not db.committed
|
||||||
|
assert list(tmp_path.iterdir()) == [source_path]
|
||||||
|
assert source_path.read_bytes() == original
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Server health audit fix: the local editor VLM returned 400s when asked for a
|
||||||
|
model id it does not serve (the client default 'Qwen3.5-9B-MLX-8bit', or the
|
||||||
|
stale caption model 'Qwen/Qwen3-VL-30B'). ``_resolve_model`` maps the requested
|
||||||
|
id to one the base actually serves."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from src import editor_agent_model as eam
|
||||||
|
|
||||||
|
|
||||||
|
def _cache(base, ids):
|
||||||
|
eam._MODELS_CACHE[base] = set(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_model_keeps_a_served_request():
|
||||||
|
base = "http://x:1/v1"
|
||||||
|
_cache(base, ["Qwen/Qwen3.5-9B", "other"])
|
||||||
|
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3.5-9B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_model_falls_back_when_requested_is_not_served():
|
||||||
|
base = "http://x:2/v1"
|
||||||
|
_cache(base, ["Qwen/Qwen3.5-9B"])
|
||||||
|
# e.g. the client's MLX build name, or a stale caption model id.
|
||||||
|
assert asyncio.run(eam._resolve_model(base, "Qwen3.5-9B-MLX-8bit", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||||
|
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3-VL-30B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_model_picks_a_served_id_when_fallback_absent():
|
||||||
|
base = "http://x:3/v1"
|
||||||
|
_cache(base, ["B", "A"])
|
||||||
|
assert asyncio.run(eam._resolve_model(base, "nope", "also-nope")) == "A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_model_keeps_request_when_served_list_unknown():
|
||||||
|
base = "http://x:4/v1"
|
||||||
|
_cache(base, [])
|
||||||
|
# Server unreachable / no models listed -> don't rewrite the request.
|
||||||
|
assert asyncio.run(eam._resolve_model(base, "some/model", "fallback")) == "some/model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_check_resolves_the_model_before_calling(monkeypatch):
|
||||||
|
base = eam.local_bases()[0]
|
||||||
|
_cache(base, ["Qwen/Qwen3.5-9B"])
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_post(b, model, messages, headers, timeout, **kwargs):
|
||||||
|
seen["model"] = model
|
||||||
|
return "DONE"
|
||||||
|
|
||||||
|
monkeypatch.setattr(eam, "_post_chat", fake_post)
|
||||||
|
out = asyncio.run(eam.self_check(
|
||||||
|
"data:image/png;base64,AAAA", "data:image/png;base64,BBBB",
|
||||||
|
"make it brighter", model="Qwen3.5-9B-MLX-8bit"))
|
||||||
|
assert seen["model"] == "Qwen/Qwen3.5-9B"
|
||||||
|
assert out["done"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_caption_resolves_the_caption_model(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_post(b, model, messages, headers, timeout, **kwargs):
|
||||||
|
seen["base"] = b
|
||||||
|
seen["model"] = model
|
||||||
|
return "a photo"
|
||||||
|
|
||||||
|
monkeypatch.setattr(eam, "_post_chat", fake_post)
|
||||||
|
import src.editor_agent_model as m
|
||||||
|
_cache(m.caption_base(), ["Qwen/Qwen3.5-9B"])
|
||||||
|
txt = asyncio.run(m.caption_image("data:image/png;base64,AAAA"))
|
||||||
|
assert txt == "a photo"
|
||||||
|
assert seen["model"] == "Qwen/Qwen3.5-9B"
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Planner routing regression suite (HARNESS-owned).
|
||||||
|
|
||||||
|
Turns PHOTOSHOP's bench prompt families (vague, restyle, bg-remove/scene,
|
||||||
|
tone/auto, generative, structural) into assertions on the ROUTE CLASS the
|
||||||
|
server planner picks, so a future planner change cannot silently re-break
|
||||||
|
routing. The classes are:
|
||||||
|
|
||||||
|
ask_user -> the planner is unsure; it must ask with 2-3 choices and
|
||||||
|
apply NO operations (never a silent tone default)
|
||||||
|
deterministic -> a deterministic editor op / recipe (adjustments, filters,
|
||||||
|
crop, text, rotate, fill, recolour ...)
|
||||||
|
ai_edit_image -> a generative image-model edit (content add/remove/replace,
|
||||||
|
restyle, look)
|
||||||
|
generate_image -> text-to-image for a brand-new image
|
||||||
|
|
||||||
|
These use the pure routing helpers (``_plan_special`` / ``_generative_kind`` /
|
||||||
|
``_fallback_plan``), so no model or network call is made.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from routes import editor_agent_routes as eaar
|
||||||
|
|
||||||
|
_STATE = {"width": 900, "height": 600}
|
||||||
|
_PROVIDER = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_planner():
|
||||||
|
eaar.set_editor_planner(None)
|
||||||
|
yield
|
||||||
|
eaar.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(eaar.setup_editor_agent_routes())
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(prompt, provider=_PROVIDER):
|
||||||
|
"""Return (route_class, ops) for a prompt using the deterministic helpers."""
|
||||||
|
special = eaar._plan_special(prompt, _STATE, provider)
|
||||||
|
if special:
|
||||||
|
steps = list(special[0])
|
||||||
|
else:
|
||||||
|
fallback = eaar._fallback_plan(prompt)
|
||||||
|
if fallback.get("ask_user"):
|
||||||
|
return "ask_user", []
|
||||||
|
steps = list(fallback.get("steps") or [])
|
||||||
|
ops = [str(s.get("op") or "") for s in steps]
|
||||||
|
if "generate_image" in ops:
|
||||||
|
return "generate_image", ops
|
||||||
|
if "ai_edit_image" in ops:
|
||||||
|
return "ai_edit_image", ops
|
||||||
|
return "deterministic", ops
|
||||||
|
|
||||||
|
|
||||||
|
# (prompt, expected route class)
|
||||||
|
BENCH_ROUTING = [
|
||||||
|
# vague -> ask (never a silent tone tweak)
|
||||||
|
("do the thing", "ask_user"),
|
||||||
|
("give it some oomph", "ask_user"),
|
||||||
|
("make it better", "ask_user"),
|
||||||
|
# whole-image restyle -> model edit
|
||||||
|
("make it look like an oil painting", "ai_edit_image"),
|
||||||
|
("turn it into an anime", "ai_edit_image"),
|
||||||
|
("make it a comic book illustration", "ai_edit_image"),
|
||||||
|
("make it look like cyberpunk", "ai_edit_image"),
|
||||||
|
# background / scene replacement -> model edit
|
||||||
|
("put her on a beach", "ai_edit_image"),
|
||||||
|
("make the background nicer", "ai_edit_image"),
|
||||||
|
("make it snowy", "ai_edit_image"),
|
||||||
|
# generative content add / remove -> model edit
|
||||||
|
("add a full moon in the sky", "ai_edit_image"),
|
||||||
|
("remove the distractions", "ai_edit_image"),
|
||||||
|
("remove the person on the left", "ai_edit_image"),
|
||||||
|
# brand-new image -> text-to-image
|
||||||
|
("create a new image of a full moon", "generate_image"),
|
||||||
|
("generate an image of a sunset", "generate_image"),
|
||||||
|
("make a logo", "generate_image"),
|
||||||
|
# tone / auto / look / structural -> deterministic
|
||||||
|
("make it black and white", "deterministic"),
|
||||||
|
("make it pop", "deterministic"),
|
||||||
|
("make it look professional", "deterministic"),
|
||||||
|
("make only the red car blue", "deterministic"),
|
||||||
|
("make a 1080x1350 Instagram post from this", "deterministic"),
|
||||||
|
("add a title TOKYO with a drop shadow", "deterministic"),
|
||||||
|
("extend the canvas to 16:9", "deterministic"),
|
||||||
|
("save as PSD with layers", "deterministic"),
|
||||||
|
("fill the white area with red", "deterministic"),
|
||||||
|
("rotate the whole image 90 degrees", "deterministic"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt,expected", BENCH_ROUTING)
|
||||||
|
def test_bench_prompts_route_to_expected_class(prompt, expected):
|
||||||
|
route_class, ops = _classify(prompt)
|
||||||
|
assert route_class == expected, f"{prompt!r} -> {route_class} {ops} (want {expected})"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_plans_offer_two_to_three_choices():
|
||||||
|
for prompt in ("do the thing", "give it some oomph", "make it better"):
|
||||||
|
plan = eaar._fallback_plan(prompt)
|
||||||
|
ask = plan.get("ask_user")
|
||||||
|
assert isinstance(ask, dict), prompt
|
||||||
|
opts = ask.get("options") or []
|
||||||
|
assert 2 <= len(opts) <= 3, (prompt, opts)
|
||||||
|
assert plan.get("steps") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_object_removal_uses_selection_plus_model_edit():
|
||||||
|
_cls, ops = _classify("remove the person on the left")
|
||||||
|
assert "ai_edit_image" in ops
|
||||||
|
assert any(op.startswith("selection.") for op in ops), ops
|
||||||
|
|
||||||
|
|
||||||
|
def test_recolour_never_uses_a_flat_fill_or_a_global_tint():
|
||||||
|
# Every recolour selects the object by description and either shifts the
|
||||||
|
# masked hue or asks the image model for the masked region — never a flat
|
||||||
|
# selection.fill and never a whole-image adjustments.apply.
|
||||||
|
for prompt in ("make only the red car blue", "make the red car white", "make the flowers bright pink"):
|
||||||
|
_cls, ops = _classify(prompt)
|
||||||
|
assert "selection.samText" in ops, (prompt, ops)
|
||||||
|
assert "selection.fill" not in ops, (prompt, ops)
|
||||||
|
assert "adjustments.apply" not in ops, (prompt, ops)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chromatic_recolour_is_a_deterministic_masked_hue_shift():
|
||||||
|
cls, ops = _classify("make only the red car blue")
|
||||||
|
assert cls == "deterministic", ops
|
||||||
|
assert ops == ["selection.samText", "adjustments.applyMasked"], ops
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_image_request_from_scratch_vs_edit_of_current_photo():
|
||||||
|
# A photo edit ("make it a <style>") must NOT be treated as a new image.
|
||||||
|
assert eaar._generative_kind("make it a comic book illustration") == "restyle"
|
||||||
|
assert eaar._is_new_image_request("make it a comic book illustration") is False
|
||||||
|
# A from-scratch generation still is.
|
||||||
|
assert eaar._is_new_image_request("create an illustration of a cat") is True
|
||||||
|
assert eaar._generative_kind("create an illustration of a cat") == "add"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_route_returns_deterministic_recolour(client):
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||||
|
r = client.post("/api/editor/plan", json={"prompt": "make only the red car blue", "state": _STATE})
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["planner"] == "deterministic"
|
||||||
|
assert [s["op"] for s in data["steps"]] == ["selection.samText", "adjustments.applyMasked"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_route_asks_for_a_vague_prompt(client):
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||||
|
r = client.post("/api/editor/plan", json={"prompt": "do the thing", "state": _STATE})
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data.get("ask_user"), data
|
||||||
|
assert data.get("steps") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_route_routes_restyle_to_model_edit(client):
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||||
|
r = client.post("/api/editor/plan", json={"prompt": "turn it into an anime", "state": _STATE})
|
||||||
|
assert r.status_code == 200
|
||||||
|
ops = [s["op"] for s in r.json()["steps"]]
|
||||||
|
assert "ai_edit_image" in ops, ops
|
||||||
@@ -11,6 +11,7 @@ from src.clean_agent_preview import (
|
|||||||
from src.turn_contract import (
|
from src.turn_contract import (
|
||||||
editor_request_instructions, requested_capabilities, selected_tools_for_request,
|
editor_request_instructions, requested_capabilities, selected_tools_for_request,
|
||||||
preserve_bound_editor_selected_tools, resolve_turn_contract,
|
preserve_bound_editor_selected_tools, resolve_turn_contract,
|
||||||
|
targets_bound_editor_request,
|
||||||
)
|
)
|
||||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||||
from src.tool_policy import ToolPolicy
|
from src.tool_policy import ToolPolicy
|
||||||
@@ -72,6 +73,16 @@ def test_direct_edits_share_the_capability_router(prompt):
|
|||||||
assert not active_editor_suggestion_request(doc, prompt)
|
assert not active_editor_suggestion_request(doc, prompt)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('prompt', [
|
||||||
|
'ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist trivs sa bra har',
|
||||||
|
'Go more into how we are thinking about the future.',
|
||||||
|
])
|
||||||
|
def test_implicit_revision_followups_route_to_active_document_only(prompt):
|
||||||
|
assert requested_capabilities(prompt, active_document=True) == {'documents'}
|
||||||
|
assert requested_capabilities(prompt, active_document=False) == frozenset()
|
||||||
|
assert targets_bound_editor_request(prompt)
|
||||||
|
|
||||||
|
|
||||||
def test_source_review_still_requires_tool_completion_after_research():
|
def test_source_review_still_requires_tool_completion_after_research():
|
||||||
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in {'web_search', 'suggest_document'}]
|
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in {'web_search', 'suggest_document'}]
|
||||||
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
|
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class _FakeSock:
|
|||||||
class _FakeIMAP:
|
class _FakeIMAP:
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def __init__(self, host, port, timeout=None, **_policy):
|
def __init__(self, host, port, timeout=None):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
@@ -60,8 +60,8 @@ def test_open_imap_connection_uses_shared_timeout_for_implicit_ssl(monkeypatch):
|
|||||||
import routes.email_helpers as helpers
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
_FakeIMAP.calls = []
|
_FakeIMAP.calls = []
|
||||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
|
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
|
||||||
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
|
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
|
||||||
|
|
||||||
conn = _open_imap_connection("imap.one.com", 993, starttls=False)
|
conn = _open_imap_connection("imap.one.com", 993, starttls=False)
|
||||||
|
|
||||||
@@ -75,8 +75,8 @@ def test_open_imap_connection_supports_starttls(monkeypatch):
|
|||||||
import routes.email_helpers as helpers
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
_FakeIMAP.calls = []
|
_FakeIMAP.calls = []
|
||||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
|
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
|
||||||
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
|
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
|
||||||
|
|
||||||
_open_imap_connection("imap.local", 143, starttls=True)
|
_open_imap_connection("imap.local", 143, starttls=True)
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ async def test_account_config_uses_shared_imap_timeout(monkeypatch):
|
|||||||
def logout(self):
|
def logout(self):
|
||||||
captured["logout"] = True
|
captured["logout"] = True
|
||||||
|
|
||||||
def fake_open(host, port, *, starttls, timeout, **_policy):
|
def fake_open(host, port, *, starttls, timeout):
|
||||||
captured["open"] = (host, port, starttls, timeout)
|
captured["open"] = (host, port, starttls, timeout)
|
||||||
return _Conn()
|
return _Conn()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Email list stale-while-revalidate (SWR) regression tests.
|
||||||
|
|
||||||
|
A plain list view must never block ~2-5 s on IMAP while we already hold a
|
||||||
|
recent snapshot or a durable index: it should return the snapshot immediately
|
||||||
|
(with ``sync.stale``) and refresh in the background. Blocking IMAP only remains
|
||||||
|
for a genuine cold start (no snapshot AND no indexed rows).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from routes.email import email_routes as er
|
||||||
|
|
||||||
|
SRC = pathlib.Path(er.__file__).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _list_endpoint():
|
||||||
|
router = er.setup_email_routes()
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "name", "") == "list_emails":
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("list_emails route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _call(endpoint, **over):
|
||||||
|
kwargs = dict(
|
||||||
|
folder="INBOX", limit=50, offset=0, filter="all", from_addr=None,
|
||||||
|
account_id=None, has_attachments=0, date_from=None, date_to=None,
|
||||||
|
cached_only=0, cache_bust=None, refresh=0, owner="pewds",
|
||||||
|
)
|
||||||
|
kwargs.update(over)
|
||||||
|
return asyncio.run(endpoint(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_returns_index_snapshot_with_a_stale_marker_without_blocking_on_imap(monkeypatch):
|
||||||
|
endpoint = _list_endpoint()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
er, "_email_index_list",
|
||||||
|
lambda owner, account_id, folder, filter_, limit, offset, has_att: (
|
||||||
|
[{"uid": "11", "subject": "Indexed", "is_read": False}], 1, "2026-10-11T00:00:00Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# The synchronous path must never open IMAP; if it did the request would
|
||||||
|
# raise instead of returning the index snapshot immediately.
|
||||||
|
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
|
||||||
|
AssertionError("SWR must not open IMAP when an index snapshot exists")))
|
||||||
|
|
||||||
|
result = _call(endpoint, cache_bust="123")
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert [e["uid"] for e in result["emails"]] == ["11"]
|
||||||
|
assert result["sync"]["stale"] is True
|
||||||
|
assert result["sync"]["source"] in ("index", "cache_stale")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatch):
|
||||||
|
# With neither a snapshot nor an index, SWR must NOT fire: the request
|
||||||
|
# falls through to the real (blocking) path, so the response is not a
|
||||||
|
# stale-marked snapshot.
|
||||||
|
endpoint = _list_endpoint()
|
||||||
|
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
|
||||||
|
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
|
||||||
|
RuntimeError("offline test")))
|
||||||
|
try:
|
||||||
|
result = _call(endpoint)
|
||||||
|
except Exception:
|
||||||
|
return # the cold path legitimately attempted real IMAP
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert not (result.get("sync") or {}).get("stale")
|
||||||
|
|
||||||
|
|
||||||
|
def test_swr_contract_is_wired_into_the_list_path():
|
||||||
|
assert "_LIST_STALE" in SRC
|
||||||
|
assert "def _list_stale_put" in SRC
|
||||||
|
assert "def _schedule_list_refresh" in SRC
|
||||||
|
assert 'sync_meta["source"] = "cache_stale"' in SRC
|
||||||
|
assert 'sync_meta["stale"] = True' in SRC
|
||||||
|
# The manual-refresh contract the UI relies on must stay intact.
|
||||||
|
assert "manual_refresh = bool(refresh)" in SRC
|
||||||
|
assert "_invalidate_list_cache(account_id, folder)" in SRC
|
||||||
|
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
|
||||||
@@ -64,7 +64,7 @@ def test_imap_connect_with_host_still_connects(monkeypatch):
|
|||||||
def login(self, user, password):
|
def login(self, user, password):
|
||||||
opened["login"] = (user, password)
|
opened["login"] = (user, password)
|
||||||
|
|
||||||
def _fake_open(host, port, *, starttls, timeout, **_policy):
|
def _fake_open(host, port, *, starttls, timeout):
|
||||||
opened["host"] = host
|
opened["host"] = host
|
||||||
return _FakeConn()
|
return _FakeConn()
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from routes.email_helpers import _send_smtp_message
|
|||||||
class _FakeSMTP:
|
class _FakeSMTP:
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def __init__(self, host, port, timeout=None, **_policy):
|
def __init__(self, host, port, timeout=None):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
@@ -54,8 +54,8 @@ def test_send_smtp_message_supports_plain_smtp(monkeypatch):
|
|||||||
import routes.email_helpers as helpers
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
_FakeSMTP.calls = []
|
_FakeSMTP.calls = []
|
||||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||||
|
|
||||||
_send_smtp_message(_cfg("none"), "from@example.com", ["to@example.com"], "hello")
|
_send_smtp_message(_cfg("none"), "from@example.com", ["to@example.com"], "hello")
|
||||||
|
|
||||||
@@ -68,8 +68,8 @@ def test_send_smtp_message_supports_explicit_starttls(monkeypatch):
|
|||||||
import routes.email_helpers as helpers
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
_FakeSMTP.calls = []
|
_FakeSMTP.calls = []
|
||||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||||
|
|
||||||
_send_smtp_message(_cfg("starttls", port=2525), "from@example.com", ["to@example.com"], "hello")
|
_send_smtp_message(_cfg("starttls", port=2525), "from@example.com", ["to@example.com"], "hello")
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ def test_send_smtp_message_defaults_587_to_starttls(monkeypatch):
|
|||||||
import routes.email_helpers as helpers
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
_FakeSMTP.calls = []
|
_FakeSMTP.calls = []
|
||||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||||
|
|
||||||
cfg = _cfg("", port=587)
|
cfg = _cfg("", port=587)
|
||||||
_send_smtp_message(cfg, "from@example.com", ["to@example.com"], "hello")
|
_send_smtp_message(cfg, "from@example.com", ["to@example.com"], "hello")
|
||||||
@@ -96,8 +96,8 @@ def test_send_smtp_message_uses_ssl_when_configured(monkeypatch):
|
|||||||
import routes.email_helpers as helpers
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
_FakeSMTP.calls = []
|
_FakeSMTP.calls = []
|
||||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||||
|
|
||||||
_send_smtp_message(_cfg("ssl", port=465), "from@example.com", ["to@example.com"], "hello")
|
_send_smtp_message(_cfg("ssl", port=465), "from@example.com", ["to@example.com"], "hello")
|
||||||
|
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ async def test_test_connection_oauth_account_uses_xoauth2_for_imap_and_smtp():
|
|||||||
|
|
||||||
with mock.patch("core.database.SessionLocal", Factory), \
|
with mock.patch("core.database.SessionLocal", Factory), \
|
||||||
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
||||||
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
|
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
|
||||||
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn), \
|
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn), \
|
||||||
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
|
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
|
||||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||||
|
|
||||||
@@ -141,8 +141,8 @@ async def test_test_connection_password_account_still_uses_login():
|
|||||||
|
|
||||||
with mock.patch("core.database.SessionLocal", Factory), \
|
with mock.patch("core.database.SessionLocal", Factory), \
|
||||||
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
||||||
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
|
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
|
||||||
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn):
|
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn):
|
||||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||||
|
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
@@ -187,8 +187,8 @@ async def test_test_connection_rejects_non_google_hosts_before_oauth_auth():
|
|||||||
|
|
||||||
with mock.patch("core.database.SessionLocal", Factory), \
|
with mock.patch("core.database.SessionLocal", Factory), \
|
||||||
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||||
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
|
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
|
||||||
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
|
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
|
||||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||||
|
|
||||||
@@ -238,8 +238,8 @@ async def test_test_connection_rejects_insecure_oauth_transports_before_auth():
|
|||||||
|
|
||||||
with mock.patch("core.database.SessionLocal", Factory), \
|
with mock.patch("core.database.SessionLocal", Factory), \
|
||||||
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||||
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
|
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
|
||||||
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
|
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
|
||||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||||
|
|
||||||
@@ -336,10 +336,10 @@ async def test_test_connection_verifies_imap_tls_before_loading_oauth_token(
|
|||||||
"routes.email_routes.ssl.create_default_context",
|
"routes.email_routes.ssl.create_default_context",
|
||||||
return_value=context,
|
return_value=context,
|
||||||
), mock.patch(
|
), mock.patch(
|
||||||
"routes.email_helpers._PolicyIMAP4",
|
"routes.email_helpers.imaplib.IMAP4",
|
||||||
return_value=starttls_conn,
|
return_value=starttls_conn,
|
||||||
) as imap_cls, mock.patch(
|
) as imap_cls, mock.patch(
|
||||||
"routes.email_helpers._PolicyIMAP4_SSL",
|
"routes.email_helpers.imaplib.IMAP4_SSL",
|
||||||
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
||||||
) as imap_ssl_cls, mock.patch(
|
) as imap_ssl_cls, mock.patch(
|
||||||
"routes.email_routes._get_valid_google_token"
|
"routes.email_routes._get_valid_google_token"
|
||||||
@@ -412,10 +412,10 @@ async def test_test_connection_verifies_smtp_tls_before_loading_oauth_token(
|
|||||||
"routes.email_routes.ssl.create_default_context",
|
"routes.email_routes.ssl.create_default_context",
|
||||||
return_value=context,
|
return_value=context,
|
||||||
), mock.patch(
|
), mock.patch(
|
||||||
"routes.email_routes._PolicySMTP",
|
"routes.email_routes.smtplib.SMTP",
|
||||||
return_value=starttls_smtp,
|
return_value=starttls_smtp,
|
||||||
) as smtp_cls, mock.patch(
|
) as smtp_cls, mock.patch(
|
||||||
"routes.email_routes._PolicySMTP_SSL",
|
"routes.email_routes.smtplib.SMTP_SSL",
|
||||||
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
||||||
) as smtp_ssl_cls, mock.patch(
|
) as smtp_ssl_cls, mock.patch(
|
||||||
"routes.email_routes._get_valid_google_token"
|
"routes.email_routes._get_valid_google_token"
|
||||||
|
|||||||
@@ -76,3 +76,158 @@ def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
|||||||
assert lookup(Query(), endpoint.base_url, 'alice', 'text-model') is None
|
assert lookup(Query(), endpoint.base_url, 'alice', 'text-model') is None
|
||||||
assert lookup(Query(), endpoint.base_url, 'alice', 'openai/gpt-image-1') is None
|
assert lookup(Query(), endpoint.base_url, 'alice', 'openai/gpt-image-1') is None
|
||||||
assert lookup(Query(), 'https://unregistered.test/v1', 'alice', 'openai/gpt-5-image') is None
|
assert lookup(Query(), 'https://unregistered.test/v1', 'alice', 'openai/gpt-5-image') is None
|
||||||
|
|
||||||
|
|
||||||
|
def _form_fields(content, content_type):
|
||||||
|
"""Parse simple multipart form fields (text parts only) from a request body."""
|
||||||
|
boundary = content_type.split('boundary=')[1].strip().strip('"')
|
||||||
|
fields = {}
|
||||||
|
for part in content.split(('--' + boundary).encode()):
|
||||||
|
if b'name="' not in part:
|
||||||
|
continue
|
||||||
|
head, _, body = part.partition(b'\r\n\r\n')
|
||||||
|
import re as _re
|
||||||
|
match = _re.search(rb'name="([^"]+)"', head)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
fields[match.group(1).decode()] = body.rstrip(b'\r\n-').decode('latin1')
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _self_hosted_inpaint(monkeypatch, image, mask, body_extra, respond, include_mask=True):
|
||||||
|
base_url = 'http://100.69.120.65:8188/v1'
|
||||||
|
endpoint = SimpleNamespace(base_url=base_url, api_key='local', owner='alice')
|
||||||
|
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
|
||||||
|
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
|
||||||
|
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
|
||||||
|
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
|
||||||
|
_real_client = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(httpx, 'AsyncClient',
|
||||||
|
lambda **kw: _real_client(transport=httpx.MockTransport(respond), **kw))
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
async def json(self):
|
||||||
|
payload = {'_endpoint': base_url, 'image': png(image), 'prompt': 'do a thing'}
|
||||||
|
if include_mask:
|
||||||
|
payload['mask'] = png(mask)
|
||||||
|
payload.update(body_extra)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||||
|
return asyncio.run(route.endpoint(Request()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_hosted_inpaint_caps_oversize_and_restores_dimensions(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
assert str(request.url) == 'http://100.69.120.65:8188/v1/images/edits'
|
||||||
|
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||||
|
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (768, 576), 'blue'))}]})
|
||||||
|
|
||||||
|
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (1600, 1200), 'red'),
|
||||||
|
Image.new('L', (1600, 1200), 255),
|
||||||
|
{'steps': 8}, respond)
|
||||||
|
# Working size is capped to a model-friendly size (<=768 long side, /16).
|
||||||
|
assert seen['size'] == '768x576'
|
||||||
|
# A requested step budget passes through up to the cap (default 8).
|
||||||
|
assert seen['steps'] == '8'
|
||||||
|
# The returned image keeps the original dimensions.
|
||||||
|
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (1600, 1200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_hosted_inpaint_clamps_steps_to_the_cap(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||||
|
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'blue'))}]})
|
||||||
|
|
||||||
|
_self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
|
||||||
|
Image.new('L', (640, 480), 255), {'steps': 20}, respond)
|
||||||
|
# Over-cap requests are clamped to the configured maximum (default 8).
|
||||||
|
assert seen['steps'] == '8'
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||||
|
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'green'))}]})
|
||||||
|
|
||||||
|
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
|
||||||
|
Image.new('L', (640, 480), 255), {}, respond)
|
||||||
|
# Under the cap: dimensions pass through untouched; with no requested count
|
||||||
|
# sent the moderate default (4) is used.
|
||||||
|
assert seen['size'] == '640x480'
|
||||||
|
assert seen['steps'] == '4'
|
||||||
|
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
|
||||||
|
"""A whole-image instruction edit may omit the mask; it must not raise
|
||||||
|
KeyError -> 400 (the health-audit regression)."""
|
||||||
|
seen = {}
|
||||||
|
mask_bytes = {}
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||||
|
# The provider must receive a (full-white) mask, not a missing one.
|
||||||
|
import re as _re
|
||||||
|
parts = request.content.split(b'--')
|
||||||
|
for part in parts:
|
||||||
|
if b'name="mask"' in part:
|
||||||
|
head, _, body = part.partition(b'\r\n\r\n')
|
||||||
|
mask_bytes['png'] = body.rstrip(b'\r\n-')
|
||||||
|
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (32, 32), 'blue'))}]})
|
||||||
|
|
||||||
|
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
|
||||||
|
None, {}, respond, include_mask=False)
|
||||||
|
assert 'image' in out, out
|
||||||
|
assert seen.get('size') == '32x32'
|
||||||
|
# The synthesized mask is image-sized and all-white (whole image editable).
|
||||||
|
mask_img = Image.open(io.BytesIO(mask_bytes['png'])).convert('L')
|
||||||
|
assert mask_img.size == (32, 32)
|
||||||
|
assert mask_img.getextrema() == (255, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_inpaint_without_mask_is_a_clean_400(monkeypatch):
|
||||||
|
"""OpenAI edits need a mask; a missing one must be a clean 400, not a
|
||||||
|
KeyError traceback."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
endpoint = SimpleNamespace(base_url='https://api.openai.com/v1', api_key='sk-x', owner='alice')
|
||||||
|
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
|
||||||
|
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
|
||||||
|
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
|
||||||
|
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
async def json(self):
|
||||||
|
return {'_endpoint': 'https://api.openai.com/v1',
|
||||||
|
'image': png(Image.new('RGB', (32, 32), 'red')), 'prompt': 'x'}
|
||||||
|
|
||||||
|
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||||
|
try:
|
||||||
|
asyncio.run(route.endpoint(Request()))
|
||||||
|
raise AssertionError('expected a 400 for a missing mask')
|
||||||
|
except HTTPException as exc:
|
||||||
|
assert exc.status_code == 400
|
||||||
|
assert 'mask' in str(exc.detail).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_hosted_inpaint_forwards_request_id(monkeypatch):
|
||||||
|
"""A client-supplied request_id is forwarded to the provider so the client
|
||||||
|
can poll GET /api/image/edit-progress for step progress."""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
seen.update(_form_fields(request.content, request.headers.get('content-type', '')))
|
||||||
|
return httpx.Response(200, json={'model': 'qwen-image-edit-2511',
|
||||||
|
'data': [{'b64_json': png(Image.new('RGBA', (32, 32), 'red'))}]})
|
||||||
|
|
||||||
|
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
|
||||||
|
Image.new('L', (32, 32), 255), {'request_id': 'job-777'}, respond)
|
||||||
|
assert 'image' in out, out
|
||||||
|
assert seen.get('request_id') == 'job-777'
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Tests for the image-op progress contract (HARNESS-owned).
|
||||||
|
|
||||||
|
The 7011 side accepts an optional client-supplied ``request_id`` on heavy image
|
||||||
|
ops and exposes ``GET /api/image/edit-progress`` which proxies the provider's
|
||||||
|
``/v1/images/progress/{request_id}`` route. When the provider has no such route
|
||||||
|
the endpoint returns ``{"available": false}`` so the client keeps an
|
||||||
|
indeterminate bar.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import routes.gallery_routes as gallery
|
||||||
|
|
||||||
|
|
||||||
|
def _route(path):
|
||||||
|
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
|
||||||
|
|
||||||
|
|
||||||
|
class _Req:
|
||||||
|
def __init__(self, query):
|
||||||
|
self.query_params = query
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_db():
|
||||||
|
class Db:
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def query(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Db()
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_progress_requires_request_id(monkeypatch):
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||||
|
route = _route("/api/image/edit-progress")
|
||||||
|
try:
|
||||||
|
asyncio.run(route.endpoint(_Req({})))
|
||||||
|
except Exception as exc: # FastAPI HTTPException
|
||||||
|
assert getattr(exc, "status_code", None) == 400
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected a 400 for a missing request_id")
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_progress_rejects_an_unsafe_request_id(monkeypatch):
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||||
|
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||||
|
route = _route("/api/image/edit-progress")
|
||||||
|
try:
|
||||||
|
asyncio.run(route.endpoint(_Req({"request_id": "../secret"})))
|
||||||
|
except Exception as exc:
|
||||||
|
assert getattr(exc, "status_code", None) == 400
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected a 400 for an unsafe request_id")
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_progress_returns_provider_payload(monkeypatch):
|
||||||
|
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||||
|
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||||
|
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
seen["url"] = str(request.url)
|
||||||
|
# Live seneca contract stages: loading | editing | compositing | done | error.
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"stage": "editing", "step": 3, "steps": 8, "eta_s": 42.0, "progress": 0.375},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: client(transport=httpx.MockTransport(respond), **k))
|
||||||
|
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc-123"})))
|
||||||
|
assert out["available"] is True
|
||||||
|
assert out["stage"] == "editing" # passed through unchanged
|
||||||
|
assert out["step"] == 3 and out["steps"] == 8
|
||||||
|
assert seen["url"].endswith("/images/progress/abc-123")
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_progress_available_false_when_provider_has_no_route(monkeypatch):
|
||||||
|
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||||
|
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||||
|
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||||
|
|
||||||
|
client = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(
|
||||||
|
httpx, "AsyncClient",
|
||||||
|
lambda **k: client(transport=httpx.MockTransport(lambda req: httpx.Response(404)), **k),
|
||||||
|
)
|
||||||
|
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
|
||||||
|
assert out["available"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_progress_available_false_when_unreachable(monkeypatch):
|
||||||
|
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||||
|
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||||
|
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||||
|
|
||||||
|
class Boom:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise httpx.ConnectError("nope")
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: Boom())
|
||||||
|
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
|
||||||
|
assert out["available"] is False
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Heavy in-process image ops must not load GB-scale models by default.
|
||||||
|
|
||||||
|
remove-bg / SAM mask / Real-ESRGAN upscale+denoise used to load rembg / torch
|
||||||
|
models inside the web process and OOM-killed the host. They now default to a
|
||||||
|
graceful 503, proxy to the seneca remote ops service when
|
||||||
|
``ODYSSEUS_IMAGE_OPS_BASE`` is set (translate to the /v1/images/* contract), and
|
||||||
|
only run in-process when explicitly enabled via ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from routes.gallery import gallery_routes as gr
|
||||||
|
|
||||||
|
|
||||||
|
HEAVY_ROUTES = [
|
||||||
|
("/api/image/remove-bg", {"image": "x"}, "Background removal"),
|
||||||
|
("/api/image/mask", {"image": "x"}, "Smart mask"),
|
||||||
|
("/api/image/upscale-local", {"image": "x"}, "AI upscale"),
|
||||||
|
("/api/image/denoise", {"image": "x"}, "AI denoise"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_env(monkeypatch):
|
||||||
|
monkeypatch.setattr(gr, "require_privilege", lambda *a, **k: None)
|
||||||
|
monkeypatch.delenv("ODYSSEUS_IMAGE_OPS_BASE", raising=False)
|
||||||
|
monkeypatch.delenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(gr.setup_gallery_routes())
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_image_models_disabled_by_default():
|
||||||
|
assert gr._local_image_models_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["1", "true", "Yes", "ON"])
|
||||||
|
def test_local_image_models_enabled_env(monkeypatch, value):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
|
||||||
|
assert gr._local_image_models_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["", "0", "no", "off", "maybe"])
|
||||||
|
def test_local_image_models_disabled_env(monkeypatch, value):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
|
||||||
|
assert gr._local_image_models_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rss_mb_is_positive():
|
||||||
|
assert gr._rss_mb() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_heavy_image_op_returns_none_when_enabled(monkeypatch):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", "1")
|
||||||
|
assert asyncio.run(gr._route_heavy_image_op("/api/image/denoise", {}, label="x")) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path,body,label", HEAVY_ROUTES)
|
||||||
|
def test_heavy_ops_503_when_disabled(client, path, body, label):
|
||||||
|
resp = client.post(path, json=body)
|
||||||
|
assert resp.status_code == 503
|
||||||
|
detail = resp.json().get("detail", "")
|
||||||
|
assert "temporarily unavailable" in detail
|
||||||
|
assert label in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_bg_disabled_does_not_import_rembg(monkeypatch):
|
||||||
|
import sys
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "rembg", None)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(gr.setup_gallery_routes())
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/image/remove-bg", json={"image": "x"})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, payload, status_code=200):
|
||||||
|
self._payload = payload
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncClient:
|
||||||
|
calls = []
|
||||||
|
response = _FakeResponse({})
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, url, json=None, files=None, data=None):
|
||||||
|
_FakeAsyncClient.calls.append((url, {"json": json, "files": files, "data": data}))
|
||||||
|
return _FakeAsyncClient.response
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(gr.setup_gallery_routes())
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_bg_proxies_to_remote_contract(monkeypatch):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||||
|
_FakeAsyncClient.calls = []
|
||||||
|
_FakeAsyncClient.response = _FakeResponse(
|
||||||
|
{"data": [{"b64_json": "RB", "mask_b64": "M", "bbox": [0, 0, 1, 1]}], "model": "birefnet"}
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["image"] == "RB"
|
||||||
|
url, kw = _FakeAsyncClient.calls[-1]
|
||||||
|
assert url == "http://seneca.test:8188/v1/images/remove-background"
|
||||||
|
assert "image" in kw["files"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_proxies_to_remote_contract(monkeypatch):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||||
|
_FakeAsyncClient.calls = []
|
||||||
|
_FakeAsyncClient.response = _FakeResponse(
|
||||||
|
{"masks": [{"b64_json": "MASK", "bbox": [10, 20, 30, 40], "score": 0.9, "label": "person"}],
|
||||||
|
"model": "sam+grounding"}
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["mask"] == "MASK"
|
||||||
|
# remote [x,y,w,h] -> app [x1,y1,x2,y2]
|
||||||
|
assert body["bbox"] == [10, 20, 40, 60]
|
||||||
|
url, kw = _FakeAsyncClient.calls[-1]
|
||||||
|
assert url == "http://seneca.test:8188/v1/images/mask"
|
||||||
|
assert kw["data"]["prompt"] == "person"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_picks_top_score_not_first(monkeypatch):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||||
|
_FakeAsyncClient.response = _FakeResponse(
|
||||||
|
{"masks": [
|
||||||
|
{"b64_json": "LOW", "bbox": [0, 0, 1, 1], "score": 0.2},
|
||||||
|
{"b64_json": "HIGH", "bbox": [0, 0, 2, 2], "score": 0.95},
|
||||||
|
{"b64_json": "MID", "bbox": [0, 0, 3, 3], "score": 0.5},
|
||||||
|
], "model": "sam+grounding"}
|
||||||
|
)
|
||||||
|
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["mask"] == "HIGH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_box_is_sent_as_xywh(monkeypatch):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||||
|
_FakeAsyncClient.calls = []
|
||||||
|
_FakeAsyncClient.response = _FakeResponse({"masks": [{"b64_json": "M", "bbox": [0, 0, 1, 1], "score": 1.0}]})
|
||||||
|
resp = _client().post("/api/image/mask", json={"image": "AAAA", "box": [10, 20, 40, 60]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
_, kw = _FakeAsyncClient.calls[-1]
|
||||||
|
assert kw["data"]["box"] == "10,20,30,40"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_remote_empty_masks_is_200_nothing_selected(monkeypatch):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||||
|
_FakeAsyncClient.response = _FakeResponse({"masks": [], "model": "sam+grounding"})
|
||||||
|
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["mask"] is None
|
||||||
|
assert body["masks"] == []
|
||||||
|
assert "Nothing selected" in body["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_proxy_surfaces_error_status(monkeypatch):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||||
|
_FakeAsyncClient.response = _FakeResponse({}, status_code=500)
|
||||||
|
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert "temporarily unavailable" in resp.json().get("detail", "")
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Image endpoint fallback: image models on mixed (non image-only) endpoints.
|
||||||
|
|
||||||
|
Chat image generation resolves its model by name across every enabled endpoint,
|
||||||
|
so a ``qwen-image-edit`` / ``gpt-image-1`` model registered on a shared
|
||||||
|
OpenAI-compatible gateway works there. The editor's generative ops used a
|
||||||
|
stricter image-only lookup and reported "No image generation endpoint
|
||||||
|
configured" on those same setups. These tests pin the fallback that makes the
|
||||||
|
editor find the same endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import routes.gallery_routes as gallery_routes
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
model_type = _Column("model_type")
|
||||||
|
is_enabled = _Column("is_enabled")
|
||||||
|
owner = _Column("owner")
|
||||||
|
|
||||||
|
|
||||||
|
class _Query:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = list(rows)
|
||||||
|
|
||||||
|
def filter(self, *predicates):
|
||||||
|
self._rows = [row for row in self._rows if all(pred(row) for pred in predicates)]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
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=None, *, enabled=True, model_type="chat", models=None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
base_url=base_url,
|
||||||
|
owner=owner,
|
||||||
|
is_enabled=enabled,
|
||||||
|
model_type=model_type,
|
||||||
|
api_key="sk-secret",
|
||||||
|
cached_models=models or [],
|
||||||
|
pinned_models=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_model(monkeypatch):
|
||||||
|
monkeypatch.setattr(gallery_routes, "ModelEndpoint", _ModelEndpoint)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_endpoint_with_image_model_is_found(monkeypatch):
|
||||||
|
_patch_model(monkeypatch)
|
||||||
|
rows = [_ep("https://gateway.example.com/v1", models=["qwen-image-edit", "llama-3"])]
|
||||||
|
|
||||||
|
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
|
||||||
|
|
||||||
|
assert ep is not None
|
||||||
|
assert ep.base_url == "https://gateway.example.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_endpoint_without_image_model_is_ignored(monkeypatch):
|
||||||
|
_patch_model(monkeypatch)
|
||||||
|
rows = [_ep("https://gateway.example.com/v1", models=["llama-3", "mistral"])]
|
||||||
|
|
||||||
|
assert gallery_routes._first_visible_image_endpoint(_DB(rows), None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_only_endpoint_wins_over_mixed(monkeypatch):
|
||||||
|
_patch_model(monkeypatch)
|
||||||
|
rows = [
|
||||||
|
_ep("https://gateway.example.com/v1", models=["gpt-image-1"]),
|
||||||
|
_ep("https://images.example.com/v1", model_type="image"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
|
||||||
|
|
||||||
|
assert ep is not None
|
||||||
|
assert ep.model_type == "image"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreachable_endpoint_reports_502():
|
||||||
|
"""The inpaint proxy must distinguish an unreachable endpoint from a provider error.
|
||||||
|
|
||||||
|
502 (gateway) is reserved for "the image server could not be reached"; 503 is
|
||||||
|
reserved for "generation is switched off on the server", which the editor maps
|
||||||
|
to its own fixed message.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src = (
|
||||||
|
Path(__file__).resolve().parents[1] / "routes" / "gallery" / "gallery_routes.py"
|
||||||
|
).read_text()
|
||||||
|
assert "Image server unreachable" in src
|
||||||
|
assert "except httpx.TransportError as exc:" in src
|
||||||
|
assert "provider HTTP" in src
|
||||||
|
assert 'raise HTTPException(503, f"Image endpoint unreachable' not in src
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Source checks for the gallery Settings-tab image-model picker.
|
||||||
|
|
||||||
|
The Quick Edit model picker lives in the editor; HARNESS mirrors it into the
|
||||||
|
gallery Settings tab, fed by GET /api/editor/image-models and sharing the
|
||||||
|
same localStorage key (`ge-ai-command-model`) so the two stay in sync.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_tab_has_image_model_picker():
|
||||||
|
assert 'id="gallery-image-model"' in GALLERY_JS
|
||||||
|
assert "/api/editor/image-models" in GALLERY_JS
|
||||||
|
assert "ge-ai-command-model" in GALLERY_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_model_picker_lives_in_the_settings_container():
|
||||||
|
settings_idx = GALLERY_JS.index('id="gallery-settings-container"')
|
||||||
|
picker_idx = GALLERY_JS.index('id="gallery-image-model"')
|
||||||
|
assert picker_idx > settings_idx
|
||||||
|
|
||||||
|
|
||||||
|
def test_picker_persists_with_the_shared_localstorage_key():
|
||||||
|
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
|
||||||
|
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GALLERY = ROOT / "static/js/gallery.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_landing_has_an_import_button_next_to_new_canvas():
|
||||||
|
"""The editor landing must offer a direct "Import" action.
|
||||||
|
|
||||||
|
It sits in the same action row as "New canvas..." / "Browse gallery" and,
|
||||||
|
on file pick, opens the editor on the chosen raster image (``openEditor``
|
||||||
|
takes an image data URL as its first argument).
|
||||||
|
"""
|
||||||
|
src = GALLERY.read_text(encoding="utf-8")
|
||||||
|
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
|
||||||
|
# Button order: New canvas → Import → Browse gallery.
|
||||||
|
new_at = src.index('id="gallery-editor-new"')
|
||||||
|
import_at = src.index('id="gallery-editor-import"')
|
||||||
|
pick_at = src.index('id="gallery-editor-pick"')
|
||||||
|
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
|
||||||
|
# Wiring opens the editor on the selected file's data URL.
|
||||||
|
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
|
||||||
|
assert "readAsDataURL(file)" in src
|
||||||
|
assert "await openEditor(reader.result, null, null, name)" in src
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Tests for the seneca single-image op proxies (HARNESS-owned).
|
||||||
|
|
||||||
|
POST /api/image/replace-background -> provider /v1/images/replace-background
|
||||||
|
POST /api/image/outpaint -> provider /v1/images/outpaint (alias /extend)
|
||||||
|
|
||||||
|
Both forward a client-supplied JPEG/PNG as multipart with the prompt/params plus
|
||||||
|
``request_id`` (so GET /api/image/edit-progress lights the progress bar) and
|
||||||
|
return the provider image as ``{image}``.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import routes.gallery_routes as gallery
|
||||||
|
|
||||||
|
_IMG_B64 = base64.b64encode(b"abc-not-a-real-png").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _route(path):
|
||||||
|
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
|
||||||
|
|
||||||
|
|
||||||
|
class _Req:
|
||||||
|
def __init__(self, body):
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_db():
|
||||||
|
class Db:
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def query(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Db()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(monkeypatch, respond):
|
||||||
|
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||||
|
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||||
|
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _respond(request):
|
||||||
|
seen["url"] = str(request.url)
|
||||||
|
seen["body"] = bytes(request.content)
|
||||||
|
return respond(request)
|
||||||
|
|
||||||
|
client = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(
|
||||||
|
httpx, "AsyncClient",
|
||||||
|
lambda **k: client(transport=httpx.MockTransport(_respond), **k),
|
||||||
|
)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(_req):
|
||||||
|
return httpx.Response(200, json={"data": [{"b64_json": "AAAA"}]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_background_forwards_multipart_and_returns_image(monkeypatch):
|
||||||
|
seen = _setup(monkeypatch, _ok)
|
||||||
|
out = asyncio.run(_route("/api/image/replace-background").endpoint(_Req({
|
||||||
|
"image": _IMG_B64, "prompt": "put her on a beach", "steps": 8,
|
||||||
|
"harmonise": True, "shadow": False, "request_id": "rb-123",
|
||||||
|
})))
|
||||||
|
assert out["image"] == "AAAA"
|
||||||
|
assert seen["url"].endswith("/images/replace-background")
|
||||||
|
body = seen["body"]
|
||||||
|
assert b"put her on a beach" in body
|
||||||
|
assert b"rb-123" in body # request_id forwarded for progress
|
||||||
|
assert b"harmonise" in body and b"shadow" in body
|
||||||
|
assert b"8" in body # steps
|
||||||
|
|
||||||
|
|
||||||
|
def test_outpaint_forwards_ratio_size_context_and_returns_image(monkeypatch):
|
||||||
|
seen = _setup(monkeypatch, _ok)
|
||||||
|
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({
|
||||||
|
"image": _IMG_B64, "prompt": "extend the sky", "ratio": "16:9",
|
||||||
|
"size": "1920x1080", "steps": 4, "context": "calm blue sky",
|
||||||
|
"request_id": "op-9",
|
||||||
|
})))
|
||||||
|
assert out["image"] == "AAAA"
|
||||||
|
assert seen["url"].endswith("/images/outpaint")
|
||||||
|
body = seen["body"]
|
||||||
|
for token in (b"16:9", b"1920x1080", b"calm blue sky", b"op-9"):
|
||||||
|
assert token in body, token
|
||||||
|
|
||||||
|
|
||||||
|
def test_outpaint_extend_alias_uses_the_same_provider_route(monkeypatch):
|
||||||
|
seen = _setup(monkeypatch, _ok)
|
||||||
|
out = asyncio.run(_route("/api/image/extend").endpoint(_Req({
|
||||||
|
"image": _IMG_B64, "prompt": "extend", "ratio": "9:16",
|
||||||
|
})))
|
||||||
|
assert out["image"] == "AAAA"
|
||||||
|
assert seen["url"].endswith("/images/outpaint")
|
||||||
|
|
||||||
|
|
||||||
|
def test_outpaint_reads_openai_style_url_result(monkeypatch):
|
||||||
|
def _respond(_req):
|
||||||
|
return httpx.Response(200, json={"data": [{"url": "http://100.69.120.65:8188/v1/images/x.png"}]})
|
||||||
|
|
||||||
|
_setup(monkeypatch, _respond)
|
||||||
|
monkeypatch.setattr(gallery, "_fetch_result_image_b64", lambda url: _async_return("ZZZZ"))
|
||||||
|
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "x"})))
|
||||||
|
assert out["image"] == "ZZZZ"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_background_502_when_provider_returns_no_image(monkeypatch):
|
||||||
|
_setup(monkeypatch, lambda _req: httpx.Response(200, json={"data": []}))
|
||||||
|
try:
|
||||||
|
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
|
||||||
|
except Exception as exc:
|
||||||
|
assert getattr(exc, "status_code", None) == 502
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected a 502 when the provider returns no image")
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_return(value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_forward(monkeypatch):
|
||||||
|
"""Replace _forward_seneca_image_op with a fake that records kwargs."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def _fake_forward(base, api_key, provider_path, image_bytes, fields, *, label, timeout=300.0):
|
||||||
|
captured["label"] = label
|
||||||
|
captured["provider_path"] = provider_path
|
||||||
|
captured["timeout"] = timeout
|
||||||
|
return {"image": "AAAA"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(gallery, "_forward_seneca_image_op", _fake_forward)
|
||||||
|
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
gallery, "_first_visible_image_endpoint",
|
||||||
|
lambda *a: SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k"),
|
||||||
|
)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_outpaint_proxy_uses_a_long_timeout(monkeypatch):
|
||||||
|
captured = _capture_forward(monkeypatch)
|
||||||
|
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "extend to 9:16"})))
|
||||||
|
assert out["image"] == "AAAA"
|
||||||
|
assert captured["provider_path"] == "/images/outpaint"
|
||||||
|
assert captured["timeout"] >= 900, captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_outpaint_extend_alias_also_uses_the_long_timeout(monkeypatch):
|
||||||
|
captured = _capture_forward(monkeypatch)
|
||||||
|
asyncio.run(_route("/api/image/extend").endpoint(_Req({"image": _IMG_B64, "prompt": "extend"})))
|
||||||
|
assert captured["timeout"] >= 900, captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_background_keeps_the_short_default_timeout(monkeypatch):
|
||||||
|
captured = _capture_forward(monkeypatch)
|
||||||
|
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64, "prompt": "beach"})))
|
||||||
|
assert captured["timeout"] == 300.0, captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_outpaint_timeout_env_override(monkeypatch):
|
||||||
|
monkeypatch.setenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", "720")
|
||||||
|
assert gallery._outpaint_timeout() == 720.0
|
||||||
|
monkeypatch.delenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", raising=False)
|
||||||
|
assert gallery._outpaint_timeout() == 900.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_graceful_shutdown_window_covers_a_long_outpaint():
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src = (Path(__file__).resolve().parent.parent / "app.py").read_text()
|
||||||
|
assert '"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900"' in src
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from services.hwfit.fit import rank_models
|
from services.hwfit.fit import rank_models
|
||||||
from services.hwfit.models import get_models, is_prequantized
|
from services.hwfit.models import get_models, is_prequantized
|
||||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
|
||||||
|
|
||||||
# Rank authored inputs rather than publication catalog snapshots.
|
|
||||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
|
||||||
|
|
||||||
|
|
||||||
def _8gb_vram_system():
|
def _8gb_vram_system():
|
||||||
@@ -20,63 +14,38 @@ def _8gb_vram_system():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
GEMMA = "google/gemma-4-12B-it"
|
def test_gemma4_12b_in_catalog():
|
||||||
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
|
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
|
||||||
|
|
||||||
|
|
||||||
def _input_row(rows, name):
|
def test_gemma4_12b_has_gguf_source():
|
||||||
return next(r for r in rows if r["name"] == name)
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
|
entry = catalog["google/gemma-4-12B-it"]
|
||||||
|
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
|
||||||
def _qat_rows(rows):
|
repos = [s["repo"] for s in entry["gguf_sources"]]
|
||||||
return [r for r in rows if r["name"].startswith(GEMMA + "-qat-")]
|
assert "unsloth/gemma-4-12B-it-GGUF" in repos
|
||||||
|
|
||||||
|
|
||||||
def test_gemma4_12b_user_catalog_row_wins_the_merge(publication_catalog, monkeypatch):
|
|
||||||
"""A dynamic cache listing the same repo must not replace or duplicate the
|
|
||||||
user-catalog row; rows only the cache knows are still merged in."""
|
|
||||||
from services.hwfit import hf_discovery
|
|
||||||
|
|
||||||
monkeypatch.setattr(hf_discovery, "load_cached_hf_collection_models", lambda: [
|
|
||||||
{"name": GEMMA, "quantization": "F16", "gguf_sources": [{"repo": "test/cache-GGUF", "file": "cache.gguf"}]},
|
|
||||||
{"name": "test/cache-only", "quantization": "F16", "gguf_sources": []},
|
|
||||||
])
|
|
||||||
merged = get_models()
|
|
||||||
names = [m["name"] for m in merged]
|
|
||||||
|
|
||||||
assert names.count(GEMMA) == 1
|
|
||||||
assert "test/cache-only" in names
|
|
||||||
entry = next(m for m in merged if m["name"] == GEMMA)
|
|
||||||
assert entry["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_gemma4_12b_ranked_row_carries_its_gguf_source(publication_catalog):
|
|
||||||
hit = next(r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20) if r["name"] == GEMMA)
|
|
||||||
assert hit["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
|
|
||||||
# The F16 input does not fit 8 GB, so the fit falls back to a GGUF quant.
|
|
||||||
assert hit["quant"] == "Q4_K_M"
|
|
||||||
|
|
||||||
|
|
||||||
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
|
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
|
||||||
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
|
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
|
||||||
names = [r["name"] for r in results]
|
names = [r["name"] for r in results]
|
||||||
assert GEMMA in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
||||||
|
|
||||||
|
|
||||||
def test_gemma4_12b_qat_entries_rank_with_their_native_quant(publication_catalog):
|
def test_gemma4_12b_qat_entries_in_catalog():
|
||||||
qat = _qat_rows(publication_catalog)
|
|
||||||
assert len(qat) == 2
|
|
||||||
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
|
|
||||||
for row in qat:
|
|
||||||
assert ranked[row["name"]]["quant"] == row["quantization"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_gemma4_12b_qat_entries_are_prequantized(publication_catalog):
|
|
||||||
catalog = {m["name"]: m for m in get_models()}
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
for row in _qat_rows(publication_catalog):
|
assert "google/gemma-4-12B-it-qat-int4" in catalog
|
||||||
assert is_prequantized(catalog[row["name"]])
|
assert "google/gemma-4-12B-it-qat-int8" in catalog
|
||||||
|
|
||||||
|
|
||||||
def test_gemma4_12b_qat_entries_have_no_gguf(publication_catalog):
|
def test_gemma4_12b_qat_entries_are_prequantized():
|
||||||
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
for row in _qat_rows(publication_catalog):
|
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
|
||||||
assert ranked[row["name"]]["gguf_sources"] == []
|
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemma4_12b_qat_entries_have_no_gguf():
|
||||||
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
|
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
|
||||||
|
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
|
||||||
|
|||||||
@@ -3,33 +3,8 @@
|
|||||||
The handler did `n = int(gpu_count)` with no guard, so `?gpu_count=abc` (or any
|
The handler did `n = int(gpu_count)` with no guard, so `?gpu_count=abc` (or any
|
||||||
non-integer) raised ValueError -> HTTP 500. A malformed count is now ignored,
|
non-integer) raised ValueError -> HTTP 500. A malformed count is now ignored,
|
||||||
matching how the neighbouring gpu_group param is already parsed.
|
matching how the neighbouring gpu_group param is already parsed.
|
||||||
|
|
||||||
The count parsing runs only after the empty-catalog early return, so these
|
|
||||||
tests rank the authored publication catalog against a fixed two-GPU system and
|
|
||||||
spy on rank_models to prove the parsing path actually ran.
|
|
||||||
"""
|
"""
|
||||||
import functools
|
|
||||||
from copy import deepcopy
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from routes.hwfit_routes import setup_hwfit_routes
|
from routes.hwfit_routes import setup_hwfit_routes
|
||||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
|
||||||
|
|
||||||
SYSTEM = {
|
|
||||||
"has_gpu": True,
|
|
||||||
"backend": "cuda",
|
|
||||||
"gpu_name": "Synthetic GPU",
|
|
||||||
"gpu_vram_gb": 24.0,
|
|
||||||
"gpu_count": 2,
|
|
||||||
"gpus": [
|
|
||||||
{"index": 0, "name": "Synthetic GPU", "vram_gb": 12.0},
|
|
||||||
{"index": 1, "name": "Synthetic GPU", "vram_gb": 12.0},
|
|
||||||
],
|
|
||||||
"gpu_groups": [{"name": "Synthetic GPU", "vram_each": 12.0, "count": 2, "indices": [0, 1], "vram_total": 24.0}],
|
|
||||||
"available_ram_gb": 32.0,
|
|
||||||
"total_ram_gb": 32.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_models():
|
def _get_models():
|
||||||
@@ -40,80 +15,24 @@ def _get_models():
|
|||||||
raise AssertionError("hwfit /models route not found")
|
raise AssertionError("hwfit /models route not found")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def test_non_numeric_gpu_count_does_not_raise():
|
||||||
def ranked_systems(publication_catalog, monkeypatch):
|
|
||||||
"""Fix detection and record every system the handler hands to rank_models."""
|
|
||||||
from services.hwfit import fit, hardware
|
|
||||||
|
|
||||||
monkeypatch.setattr(hardware, "detect_system", lambda **_: deepcopy(SYSTEM))
|
|
||||||
calls = []
|
|
||||||
real_rank = fit.rank_models
|
|
||||||
|
|
||||||
@functools.wraps(real_rank)
|
|
||||||
def spy(system, **kwargs):
|
|
||||||
calls.append(deepcopy(system))
|
|
||||||
return real_rank(system, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(fit, "rank_models", spy)
|
|
||||||
return calls
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_ranked(result, ranked_systems):
|
|
||||||
assert isinstance(result, dict)
|
|
||||||
assert "error" not in result
|
|
||||||
assert isinstance(result["models"], list) and result["models"]
|
|
||||||
# Reaching rank_models means the empty-catalog return was not taken and the
|
|
||||||
# manual-hardware and gpu_count parsing ran first.
|
|
||||||
assert len(ranked_systems) == 1
|
|
||||||
assert ranked_systems[0] == result["system"]
|
|
||||||
return result["system"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_numeric_gpu_count_does_not_raise(ranked_systems):
|
|
||||||
handler = _get_models()
|
handler = _get_models()
|
||||||
# Previously raised ValueError (HTTP 500); now degrades to a normal ranking.
|
# Previously raised ValueError (HTTP 500); now degrades to a normal ranking.
|
||||||
system = _assert_ranked(handler(gpu_count="abc"), ranked_systems)
|
result = handler(gpu_count="abc")
|
||||||
# Ignored like an omitted count: the auto pool keeps both GPUs and no
|
assert isinstance(result, dict)
|
||||||
# explicit-count GPU-only pin is applied.
|
|
||||||
assert system["detected_gpu_count"] == 2
|
|
||||||
assert system["active_group"]["use_count"] == 2
|
|
||||||
assert system["gpu_count"] == 2
|
|
||||||
assert "gpu_only" not in system
|
|
||||||
|
|
||||||
|
|
||||||
def test_numeric_gpu_count_still_accepted(ranked_systems):
|
def test_numeric_gpu_count_still_accepted():
|
||||||
handler = _get_models()
|
handler = _get_models()
|
||||||
system = _assert_ranked(handler(gpu_count="0"), ranked_systems)
|
result = handler(gpu_count="0")
|
||||||
# 0 switches to RAM-only ranking.
|
assert isinstance(result, dict)
|
||||||
assert system["detected_gpu_count"] == 2
|
|
||||||
assert system["has_gpu"] is False
|
|
||||||
assert system["gpu_count"] == 0
|
|
||||||
assert system["gpu_only"] is False
|
|
||||||
assert "active_group" not in system
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_numeric_manual_gpu_count_does_not_raise(ranked_systems):
|
def test_non_numeric_manual_gpu_count_does_not_raise():
|
||||||
# manual_gpu_count is the other count param on this endpoint (the hardware
|
# manual_gpu_count is the other count param on this endpoint (the hardware
|
||||||
# simulator in _apply_manual_hardware). A non-numeric value must also degrade
|
# simulator in _apply_manual_hardware). A non-numeric value must also degrade
|
||||||
# (default to 1) rather than 500, so the endpoint's count parsing is fully
|
# (default to 1) rather than 500, so the endpoint's count parsing is fully
|
||||||
# covered.
|
# covered.
|
||||||
handler = _get_models()
|
handler = _get_models()
|
||||||
system = _assert_ranked(handler(manual_mode="gpu", manual_gpu_count="abc"), ranked_systems)
|
result = handler(manual_mode="gpu", manual_gpu_count="abc")
|
||||||
assert system["manual_hardware"] is True
|
assert isinstance(result, dict)
|
||||||
assert system["gpu_name"] == "Simulated CUDA GPU"
|
|
||||||
assert system["gpu_count"] == 1
|
|
||||||
assert system["gpu_groups"][0]["count"] == 1
|
|
||||||
assert system["detected_gpu_count"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_catalog_returns_before_count_parsing(ranked_systems, monkeypatch):
|
|
||||||
# Control: without catalog rows the handler stops before the parsing above,
|
|
||||||
# which is why the tests in this module must rank the authored catalog.
|
|
||||||
from services.hwfit import models
|
|
||||||
|
|
||||||
monkeypatch.setattr(models, "get_models", lambda: [])
|
|
||||||
result = _get_models()(gpu_count="abc", manual_mode="gpu", manual_gpu_count="abc")
|
|
||||||
assert result["models"] == []
|
|
||||||
assert "Model catalog is empty" in result["error"]
|
|
||||||
assert ranked_systems == []
|
|
||||||
assert "detected_gpu_count" not in result["system"]
|
|
||||||
|
|||||||
+10
-17
@@ -6,15 +6,9 @@ guarantee that non-macOS (Linux/Windows) detection is unchanged.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from services.hwfit import hardware
|
from services.hwfit import hardware
|
||||||
from services.hwfit.fit import rank_models
|
from services.hwfit.fit import rank_models
|
||||||
from services.hwfit.models import get_models
|
from services.hwfit.models import get_models
|
||||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
|
||||||
|
|
||||||
# Rank authored inputs rather than publication catalog snapshots.
|
|
||||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
|
||||||
|
|
||||||
|
|
||||||
def _metal_system(ram_gb=16.0, vram_gb=10.7):
|
def _metal_system(ram_gb=16.0, vram_gb=10.7):
|
||||||
@@ -84,20 +78,19 @@ def test_only_gguf_or_mlx_models_recommended_on_metal():
|
|||||||
assert unservable == [], f"{len(unservable)} non-servable models on Metal, e.g. {unservable[:3]}"
|
assert unservable == [], f"{len(unservable)} non-servable models on Metal, e.g. {unservable[:3]}"
|
||||||
|
|
||||||
|
|
||||||
def test_qwen_gguf_rows_rank_with_their_gguf_downloads_on_metal(publication_catalog):
|
def test_qwen_catalog_entries_point_at_verified_gguf_repos():
|
||||||
"""Qwen GGUF-looking Cookbook rows must download the GGUF repo/file their
|
"""Qwen GGUF-looking Cookbook rows must download GGUF repos, not the base
|
||||||
catalog row names, not the base safetensors repository."""
|
safetensors repositories."""
|
||||||
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
expected = {
|
expected = {
|
||||||
r["name"]: r["gguf_sources"]
|
"Qwen/Qwen3.5-9B": ("unsloth/Qwen3.5-9B-GGUF", "Qwen3.5-9B-Q4_K_M.gguf"),
|
||||||
for r in publication_catalog
|
"Qwen/Qwen3.6-27B": ("unsloth/Qwen3.6-27B-GGUF", "Qwen3.6-27B-Q4_K_M.gguf"),
|
||||||
if r["name"].startswith("Qwen/Qwen3.") and r["gguf_sources"]
|
"Qwen/Qwen3.6-35B-A3B": ("unsloth/Qwen3.6-35B-A3B-GGUF", "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"),
|
||||||
}
|
}
|
||||||
assert len(expected) == 3
|
|
||||||
ranked = {r["name"]: r for r in rank_models(_metal_system(), search="Qwen/Qwen3.", limit=50)}
|
|
||||||
|
|
||||||
for model_name, sources in expected.items():
|
for model_name, (repo, filename) in expected.items():
|
||||||
assert ranked[model_name]["gguf_sources"] == sources
|
sources = catalog[model_name].get("gguf_sources") or []
|
||||||
assert all(src["repo"] != model_name and src["file"].endswith(".gguf") for src in ranked[model_name]["gguf_sources"])
|
assert any(src.get("repo") == repo and src.get("file") == filename for src in sources)
|
||||||
|
|
||||||
|
|
||||||
def test_safetensors_models_still_recommended_on_cuda():
|
def test_safetensors_models_still_recommended_on_cuda():
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from services.hwfit.fit import analyze_model, rank_models
|
from services.hwfit.fit import analyze_model, rank_models
|
||||||
from services.hwfit.models import (
|
from services.hwfit.models import (
|
||||||
get_models,
|
get_models,
|
||||||
infer_quantization_from_name,
|
infer_quantization_from_name,
|
||||||
is_prequantized,
|
is_prequantized,
|
||||||
)
|
)
|
||||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
|
||||||
|
|
||||||
# Rank authored inputs rather than publication catalog snapshots.
|
|
||||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
|
||||||
|
|
||||||
|
|
||||||
def _dual_5060ti_system():
|
def _dual_5060ti_system():
|
||||||
@@ -41,7 +35,7 @@ def test_infers_native_hf_quant_formats_from_repo_names():
|
|||||||
|
|
||||||
def test_nvfp4_catalog_quant_is_preserved():
|
def test_nvfp4_catalog_quant_is_preserved():
|
||||||
catalog = {m["name"]: m for m in get_models()}
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
model = catalog["test/native-NVFP4"]
|
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
|
||||||
|
|
||||||
assert model["quantization"] == "NVFP4"
|
assert model["quantization"] == "NVFP4"
|
||||||
assert is_prequantized(model)
|
assert is_prequantized(model)
|
||||||
@@ -49,7 +43,7 @@ def test_nvfp4_catalog_quant_is_preserved():
|
|||||||
|
|
||||||
def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
|
def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
|
||||||
catalog = {m["name"]: m for m in get_models()}
|
catalog = {m["name"]: m for m in get_models()}
|
||||||
model = catalog["test/native-NVFP4"]
|
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
|
||||||
|
|
||||||
fit = analyze_model(model, _dual_5060ti_system())
|
fit = analyze_model(model, _dual_5060ti_system())
|
||||||
assert fit["quant"] == "NVFP4"
|
assert fit["quant"] == "NVFP4"
|
||||||
@@ -57,10 +51,10 @@ def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
|
|||||||
|
|
||||||
results = rank_models(
|
results = rank_models(
|
||||||
_dual_5060ti_system(),
|
_dual_5060ti_system(),
|
||||||
search="native-NVFP4",
|
search="Qwen3.5-122B-A10B-NVFP4",
|
||||||
limit=10,
|
limit=10,
|
||||||
)
|
)
|
||||||
hit = next(r for r in results if r["name"] == "test/native-NVFP4")
|
hit = next(r for r in results if r["name"] == "txn545/Qwen3.5-122B-A10B-NVFP4")
|
||||||
assert hit["quant"] == "NVFP4"
|
assert hit["quant"] == "NVFP4"
|
||||||
assert hit["run_mode"] != "cpu_offload"
|
assert hit["run_mode"] != "cpu_offload"
|
||||||
|
|
||||||
|
|||||||
@@ -120,8 +120,9 @@ def test_disabled_generation_is_not_restored():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('incognito', [False, True])
|
||||||
@pytest.mark.parametrize('exit_code', [None, 0])
|
@pytest.mark.parametrize('exit_code', [None, 0])
|
||||||
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code):
|
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code, incognito):
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from src import ai_interaction, tool_execution
|
from src import ai_interaction, tool_execution
|
||||||
from src.agent_runtime.journal import ActionJournal, bind_journal
|
from src.agent_runtime.journal import ActionJournal, bind_journal
|
||||||
@@ -145,6 +146,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
|||||||
_, denied = await execute(block, owner='pewds', session_id='fixture',
|
_, denied = await execute(block, owner='pewds', session_id='fixture',
|
||||||
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||||
_, result = await execute(block, owner='pewds', session_id='fixture',
|
_, result = await execute(block, owner='pewds', session_id='fixture',
|
||||||
|
client_runtime_context={'web_ui_state': {'incognito': incognito}},
|
||||||
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||||
assert denied['exit_code'] != 0
|
assert denied['exit_code'] != 0
|
||||||
assert journal.actions[0].execution_id is None
|
assert journal.actions[0].execution_id is None
|
||||||
@@ -152,7 +154,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
|||||||
assert journal.actions[1].execution_id is not None
|
assert journal.actions[1].execution_id is not None
|
||||||
assert journal.actions[1].outcome['authoritative'] is (exit_code == 0)
|
assert journal.actions[1].outcome['authoritative'] is (exit_code == 0)
|
||||||
assert result['image_id'] == 'test'
|
assert result['image_id'] == 'test'
|
||||||
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture'})]
|
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture', **({'incognito': True} if incognito else {})})]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""The heavy image-edit CPU work must run off the event loop, and the app must
|
||||||
|
expose in-flight heavy-op counts + a graceful shutdown so a deploy restart does
|
||||||
|
not drop a 60-150 s edit."""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from routes.gallery import gallery_routes as gr
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _png_b64(w, h, color=(120, 160, 210)):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGBA", (w, h), color).save(buf, format="PNG")
|
||||||
|
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_local_edit_caps_large_input():
|
||||||
|
src, mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(4000, 3000), None, 768)
|
||||||
|
assert orig == (4000, 3000)
|
||||||
|
assert max(mw, mh) <= 768 and mw % 16 == 0 and mh % 16 == 0
|
||||||
|
assert mask # a full-white mask is synthesised when none is supplied
|
||||||
|
assert Image.open(io.BytesIO(src)).size == (mw, mh)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_local_edit_leaves_small_input_unchanged():
|
||||||
|
_src, _mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(96, 96), _png_b64(96, 96, (255, 255, 255)), 768)
|
||||||
|
assert orig == (96, 96) and (mw, mh) == (96, 96)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_local_b64_resizes_back_to_original():
|
||||||
|
raw = _png_b64(768, 576)
|
||||||
|
out = gr._restore_local_b64(raw, (4000, 3000), (768, 576))
|
||||||
|
assert Image.open(io.BytesIO(base64.b64decode(out))).size == (4000, 3000)
|
||||||
|
# a same-size result is returned untouched
|
||||||
|
assert gr._restore_local_b64(raw, (768, 576), (768, 576)) == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_inpaint_offloads_cpu_work_to_a_thread():
|
||||||
|
src = (ROOT / "routes/gallery/gallery_routes.py").read_text()
|
||||||
|
assert "await asyncio.to_thread(\n _prepare_local_edit" in src
|
||||||
|
assert "asyncio.to_thread(\n _restore_local_b64" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_exposes_busy_count_and_graceful_shutdown():
|
||||||
|
src = (ROOT / "app.py").read_text()
|
||||||
|
assert "busy_image_ops" in src
|
||||||
|
assert '@app.get("/api/busy")' in src
|
||||||
|
assert "_HEAVY_IMAGE_OP_PREFIXES" in src
|
||||||
|
assert "timeout_graceful_shutdown" in src
|
||||||
|
assert "activity.begin_image_op" in src and "activity.end_image_op" in src
|
||||||
@@ -33,6 +33,38 @@ def test_native_generation_respects_admin_disable(monkeypatch):
|
|||||||
assert 'disabled' in result['error']
|
assert 'disabled' in result['error']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('editing', [False, True])
|
||||||
|
def test_incognito_images_never_write_gallery_or_files(image_transport, editing):
|
||||||
|
configure, source, requests, rows = image_transport
|
||||||
|
configure('https://openrouter.ai/api/v1')
|
||||||
|
if editing:
|
||||||
|
result = asyncio.run(ai_interaction.do_edit_image(
|
||||||
|
'Private edit', str(source), model_spec='openai/gpt-5-image',
|
||||||
|
owner='alice', session_id='private-chat', incognito=True))
|
||||||
|
else:
|
||||||
|
result = asyncio.run(ai_interaction.do_generate_image(
|
||||||
|
'Private image\nopenai/gpt-5-image', owner='alice',
|
||||||
|
session_id='private-chat', incognito=True))
|
||||||
|
assert 'error' not in result
|
||||||
|
assert result['image_url'].startswith('data:image/png;base64,')
|
||||||
|
assert base64.b64decode(result['image_url'].split(',', 1)[1]) == source.read_bytes()
|
||||||
|
assert not result['image_id']
|
||||||
|
assert not rows
|
||||||
|
assert not (source.parent / 'generated').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_incognito_url_image_is_not_persisted(image_transport, monkeypatch):
|
||||||
|
configure, source, requests, rows = image_transport
|
||||||
|
configure('https://api.openai.com/v1', body={'data': [{'url': 'https://example.com/output.png'}]})
|
||||||
|
monkeypatch.setattr(httpx, 'get', lambda *args, **kwargs: httpx.Response(200, content=source.read_bytes()))
|
||||||
|
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
|
||||||
|
result = asyncio.run(ai_interaction.do_generate_image(
|
||||||
|
'Private image\nopenai/gpt-5-image', owner='alice', incognito=True))
|
||||||
|
assert result['image_url'].startswith('data:image/png;base64,')
|
||||||
|
assert not rows
|
||||||
|
assert not (source.parent / 'generated').exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def image_transport(monkeypatch, tmp_path):
|
def image_transport(monkeypatch, tmp_path):
|
||||||
rows = []
|
rows = []
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ def test_open_imap_connection_shuts_down_on_starttls_failure(monkeypatch):
|
|||||||
))
|
))
|
||||||
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
|
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
|
||||||
|
|
||||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", lambda *a, **kw: conn)
|
monkeypatch.setattr(helpers.imaplib, "IMAP4", lambda *a, **kw: conn)
|
||||||
|
|
||||||
raised = False
|
raised = False
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
"""Scoped ``photo_editor`` capability family: routing, tools, and the
|
||||||
|
Quick Edit agent endpoint.
|
||||||
|
|
||||||
|
The family is context-only: it is offered to the model ONLY when an editor
|
||||||
|
session / edit-this-photo request explicitly selects it, never inferred from
|
||||||
|
message text and never part of the general tool list.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.turn_contract import (
|
||||||
|
FAMILY_TOOLS,
|
||||||
|
requested_capabilities,
|
||||||
|
resolve_full_inventory_contract,
|
||||||
|
resolve_turn_contract,
|
||||||
|
)
|
||||||
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||||
|
from src.tool_policy import ToolPolicy
|
||||||
|
from src.tool_types import TOOL_TAGS
|
||||||
|
from src.agent_tools import TOOL_HANDLERS
|
||||||
|
from src.tool_index import BUILTIN_TOOL_DESCRIPTIONS
|
||||||
|
from src.tool_capabilities import KNOWN_CAPABILITY_TOOLS
|
||||||
|
from src.tool_security import PLAN_MODE_READONLY_TOOLS, _PLAN_MODE_KNOWN_MUTATORS
|
||||||
|
|
||||||
|
PHOTO_TOOLS = frozenset(
|
||||||
|
{"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image"}
|
||||||
|
)
|
||||||
|
FAMILY = FAMILY_TOOLS["photo_editor"]
|
||||||
|
CORE_NAMES = frozenset({"bash", "python", "web_search", "web_fetch", "write_file"})
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_names():
|
||||||
|
return {s["function"]["name"] for s in FUNCTION_TOOL_SCHEMAS}
|
||||||
|
|
||||||
|
|
||||||
|
def test_family_contains_scoped_tools_only():
|
||||||
|
assert FAMILY == PHOTO_TOOLS | {"generate_image"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_contract_offers_only_editor_tools_and_ask_user():
|
||||||
|
contract = resolve_turn_contract(
|
||||||
|
capabilities={"photo_editor"},
|
||||||
|
schemas=FUNCTION_TOOL_SCHEMAS,
|
||||||
|
policy=ToolPolicy(),
|
||||||
|
required_capabilities={"photo_editor"},
|
||||||
|
)
|
||||||
|
assert contract.offered == FAMILY | {"ask_user", "update_plan"}
|
||||||
|
assert not (CORE_NAMES & contract.offered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_photo_editor_is_never_inferred_from_message_text():
|
||||||
|
for message in (
|
||||||
|
"make it black and white",
|
||||||
|
"generate an image of a cat",
|
||||||
|
"remove the person on the left",
|
||||||
|
"add a full moon in the sky",
|
||||||
|
"blur the background behind the person",
|
||||||
|
):
|
||||||
|
caps = requested_capabilities(message)
|
||||||
|
assert "photo_editor" not in caps, message
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_image_keeps_its_own_family_without_leaking_photo_editor():
|
||||||
|
caps = requested_capabilities("Create a note and generate an image of a cat")
|
||||||
|
assert "image_generation" in caps
|
||||||
|
assert "photo_editor" not in caps
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_tools_are_registered_everywhere():
|
||||||
|
schema_names = _schema_names()
|
||||||
|
for name in PHOTO_TOOLS:
|
||||||
|
assert name in TOOL_TAGS, name
|
||||||
|
assert name in TOOL_HANDLERS, name
|
||||||
|
assert name in BUILTIN_TOOL_DESCRIPTIONS, name
|
||||||
|
assert name in KNOWN_CAPABILITY_TOOLS, name
|
||||||
|
assert name in schema_names, name
|
||||||
|
assert "generate_image" in FAMILY
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_tools_plan_mode_partition():
|
||||||
|
assert "get_editor_screenshot" in PLAN_MODE_READONLY_TOOLS
|
||||||
|
for name in ("plan_edit", "run_editor_command", "ai_edit_image"):
|
||||||
|
assert name in _PLAN_MODE_KNOWN_MUTATORS, name
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_inventory_contains_the_editor_tools():
|
||||||
|
preview = resolve_full_inventory_contract(
|
||||||
|
schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()
|
||||||
|
)
|
||||||
|
assert FAMILY <= set(preview.offered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scope_preview_contract_keeps_only_the_editor_family():
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from src.clean_agent_preview import scope_preview_contract
|
||||||
|
|
||||||
|
preview = resolve_full_inventory_contract(
|
||||||
|
schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()
|
||||||
|
)
|
||||||
|
routed = resolve_turn_contract(
|
||||||
|
capabilities={"photo_editor"},
|
||||||
|
schemas=FUNCTION_TOOL_SCHEMAS,
|
||||||
|
policy=ToolPolicy(),
|
||||||
|
required_capabilities={"photo_editor"},
|
||||||
|
)
|
||||||
|
scoped = scope_preview_contract(
|
||||||
|
replace(preview, selection_mode="full_compact_experiment"),
|
||||||
|
routed,
|
||||||
|
frozenset({"photo_editor"}),
|
||||||
|
extra_tools=frozenset({"ask_user"}),
|
||||||
|
)
|
||||||
|
assert FAMILY <= set(scoped.offered)
|
||||||
|
assert "ask_user" in scoped.offered
|
||||||
|
assert not (CORE_NAMES & set(scoped.offered))
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_agent_endpoint_reports_scoped_tools():
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import routes.editor_agent_routes as eaar
|
||||||
|
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: {"reply": "ok", "steps": [], "done": True})
|
||||||
|
try:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(eaar.setup_editor_agent_routes())
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/editor/agent",
|
||||||
|
json={"prompt": "hello there", "session": "s1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert PHOTO_TOOLS <= set(data["tools_offered"])
|
||||||
|
assert "generate_image" in data["tools_offered"]
|
||||||
|
assert not (CORE_NAMES & set(data["tools_offered"]))
|
||||||
|
assert data["trace"][0]["type"] == "session"
|
||||||
|
finally:
|
||||||
|
eaar.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _route_contract(monkeypatch, message, *, attachment_meta=()):
|
||||||
|
from routes import chat_routes
|
||||||
|
from src import tool_security
|
||||||
|
from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint
|
||||||
|
|
||||||
|
with monkeypatch.context() as route_patch:
|
||||||
|
endpoint = _chat_stream_endpoint(
|
||||||
|
route_patch, "agent", {},
|
||||||
|
session_model="odysseus-qwen3.5-tools-pre-heretic",
|
||||||
|
attachment_meta=attachment_meta,
|
||||||
|
)
|
||||||
|
route_patch.setattr(
|
||||||
|
chat_routes, "coerce_message_and_session",
|
||||||
|
lambda *args, **kwargs: (message, "session-1"),
|
||||||
|
)
|
||||||
|
route_patch.setattr(
|
||||||
|
tool_security, "owner_is_admin_or_single_user", lambda owner: True,
|
||||||
|
)
|
||||||
|
observed = []
|
||||||
|
|
||||||
|
async def capture_agent(*args, **kwargs):
|
||||||
|
observed.append(kwargs["turn_contract"])
|
||||||
|
yield 'data: {"delta":"ok"}\n\n'
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
route_patch.setattr(chat_routes, "stream_agent_loop", capture_agent)
|
||||||
|
request = _RouteRequest("agent")
|
||||||
|
request._form.update({
|
||||||
|
"message": message, "compare_mode": "false",
|
||||||
|
"allow_web_search": "false", "allow_bash": "false",
|
||||||
|
})
|
||||||
|
response = await endpoint(request)
|
||||||
|
async for _ in response.body_iterator:
|
||||||
|
pass
|
||||||
|
assert len(observed) == 1
|
||||||
|
return observed[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normal_chat_turn_offers_no_editor_tools(monkeypatch):
|
||||||
|
for message in ("what is the capital of France", "write a haiku"):
|
||||||
|
contract = await _route_contract(monkeypatch, message)
|
||||||
|
assert "photo_editor" not in contract.capabilities, message
|
||||||
|
assert not (PHOTO_TOOLS & set(contract.offered)), message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attached_photo_edit_selects_photo_editor_family(monkeypatch):
|
||||||
|
contract = await _route_contract(
|
||||||
|
monkeypatch,
|
||||||
|
"make this black and white",
|
||||||
|
attachment_meta=[{"id": "att1", "mime": "image/png", "name": "x.png"}],
|
||||||
|
)
|
||||||
|
assert contract.capabilities == {"photo_editor"}
|
||||||
|
assert set(contract.offered) == FAMILY | {"ask_user"}
|
||||||
|
assert not (CORE_NAMES & set(contract.offered))
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_context_guides_open_or_absent_editor():
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src = (Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "The image editor is open (session " in src
|
||||||
|
assert "no image editor is open" in src
|
||||||
|
assert "the attached image in the editor (Quick Edit)" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_generative_intent_classification():
|
||||||
|
from routes.editor_agent_routes import _generative_kind
|
||||||
|
|
||||||
|
assert _generative_kind("remove the person on the left") == "remove"
|
||||||
|
assert _generative_kind("erase the sign in the background") == "remove"
|
||||||
|
assert _generative_kind("add a full moon in the sky") == "add"
|
||||||
|
assert _generative_kind("put a bird on the fence") == "add"
|
||||||
|
assert _generative_kind("replace the sky with a sunset") == "replace"
|
||||||
|
assert _generative_kind("change the background to night") == "replace"
|
||||||
|
# Non-generative edits must stay deterministic.
|
||||||
|
assert _generative_kind("make it black and white") is None
|
||||||
|
assert _generative_kind("make it brighter") is None
|
||||||
|
assert _generative_kind("add a vignette") is None
|
||||||
|
assert _generative_kind("remove the background") is None
|
||||||
|
assert _generative_kind("sharpen the image") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_generative_step_uses_ai_content_tools():
|
||||||
|
from routes.editor_agent_routes import _generative_step
|
||||||
|
|
||||||
|
assert _generative_step("remove", "remove the person")["op"] == "ai_edit_image"
|
||||||
|
assert _generative_step("replace", "replace the sky")["op"] == "ai_edit_image"
|
||||||
|
# Adding content to an existing photo is an instruction edit, not generate_image.
|
||||||
|
assert _generative_step("add", "add a moon")["op"] == "ai_edit_image"
|
||||||
|
assert _generative_step("add", "add a moon")["params"]["mode"] == "instruction"
|
||||||
|
# generate_image only for a brand-new image.
|
||||||
|
assert _generative_step("add", "create a new image of a moon")["op"] == "generate_image"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generative_mode_instruction_vs_masked():
|
||||||
|
from routes.editor_agent_routes import _generative_mode
|
||||||
|
|
||||||
|
# Masked only for an explicit regional removal of a clearly identified object.
|
||||||
|
assert _generative_mode("remove", "remove the person on the left") == "masked"
|
||||||
|
# Diffuse cleanup, replacement, season/style and additions use instruction mode.
|
||||||
|
assert _generative_mode("remove", "remove the distractions") == "instruction"
|
||||||
|
assert _generative_mode("replace", "make the background nicer") == "instruction"
|
||||||
|
assert _generative_mode("add", "add a full moon in the sky") == "instruction"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_generative_replaces_deterministic_approximations():
|
||||||
|
from routes.editor_agent_routes import _ensure_generative
|
||||||
|
|
||||||
|
bad = [{"op": "selection.rectangle", "params": {}}, {"op": "adjustments.apply", "params": {}}]
|
||||||
|
removal = _ensure_generative("remove the person on the left", list(bad))
|
||||||
|
# A mask selection step now precedes the masked AI edit.
|
||||||
|
assert removal[-1]["op"] == "ai_edit_image"
|
||||||
|
assert removal[-1]["params"]["mode"] == "masked"
|
||||||
|
assert all(s["op"].startswith("selection.") for s in removal[:-1])
|
||||||
|
# Adding an object to an existing photo is an instruction edit (no selection).
|
||||||
|
addition = _ensure_generative("add a full moon in the sky", list(bad))
|
||||||
|
assert addition[-1]["op"] == "ai_edit_image"
|
||||||
|
assert addition[-1]["params"]["mode"] == "instruction"
|
||||||
|
# Non-generative plans are left untouched.
|
||||||
|
assert _ensure_generative("make it black and white", list(bad)) == bad
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_plan_routes_generative_intents():
|
||||||
|
import routes.editor_agent_routes as eaar
|
||||||
|
|
||||||
|
removal = eaar._fallback_plan("remove the person on the left")
|
||||||
|
assert removal["steps"][-1]["op"] == "ai_edit_image"
|
||||||
|
assert removal["steps"][-1]["params"]["mode"] == "masked"
|
||||||
|
addition = eaar._fallback_plan("add a full moon in the sky")
|
||||||
|
assert addition["steps"][-1]["op"] == "ai_edit_image"
|
||||||
|
assert addition["steps"][-1]["params"]["mode"] == "instruction"
|
||||||
|
plain = eaar._fallback_plan("make it black and white")
|
||||||
|
assert plain["steps"][0]["op"] == "adjustments.apply"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_message_is_actionable():
|
||||||
|
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||||
|
|
||||||
|
assert "image generation provider" in IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||||
|
assert "Settings > Models" in IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_provider_respects_disabled_setting(monkeypatch):
|
||||||
|
import src.settings as settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": False})
|
||||||
|
from src.ai_interaction import image_editing_provider_available
|
||||||
|
|
||||||
|
assert image_editing_provider_available(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_client():
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import routes.editor_agent_routes as eaar
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(eaar.setup_editor_agent_routes())
|
||||||
|
return eaar, TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"prompt",
|
||||||
|
["remove the person on the left", "add a full moon in the sky"],
|
||||||
|
)
|
||||||
|
def test_editor_agent_blocks_generative_without_provider(monkeypatch, prompt):
|
||||||
|
"""No image provider -> zero canvas changes + a plain provider message."""
|
||||||
|
eaar, client = _agent_client()
|
||||||
|
# Planner proposes an unrelated deterministic edit, as the buggy run did.
|
||||||
|
eaar.set_editor_planner(
|
||||||
|
lambda messages, owner: {"reply": "done", "steps": [
|
||||||
|
{"op": "selection.rectangle", "params": {}},
|
||||||
|
{"op": "adjustments.apply", "params": {}},
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: False)
|
||||||
|
try:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/editor/agent", json={"prompt": prompt, "session": "s1"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["needs_provider"] is True
|
||||||
|
assert data["blocked"] is True
|
||||||
|
assert "image generation provider" in data["message"]
|
||||||
|
assert data["image_provider"] is False
|
||||||
|
assert data["results"] == []
|
||||||
|
# Nothing was executed through the bridge (zero canvas changes).
|
||||||
|
assert not any(t.get("type") == "step" for t in data["trace"])
|
||||||
|
assert any(t.get("reason") == "image_provider_unavailable" for t in data["trace"])
|
||||||
|
# The proposed deterministic plan was replaced by the correct AI op.
|
||||||
|
ops = [s["op"] for s in data["steps"]]
|
||||||
|
assert ops[-1] in ("ai_edit_image", "generate_image")
|
||||||
|
finally:
|
||||||
|
eaar.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_agent_prefers_ai_op_over_deterministic_when_provider_present(monkeypatch):
|
||||||
|
eaar, client = _agent_client()
|
||||||
|
eaar.set_editor_planner(
|
||||||
|
lambda messages, owner: {"reply": "done", "steps": [
|
||||||
|
{"op": "selection.rectangle", "params": {}},
|
||||||
|
{"op": "adjustments.apply", "params": {}},
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True)
|
||||||
|
|
||||||
|
async def _instant_wait_for(awaitable, *args, **kwargs):
|
||||||
|
# The plan now starts with a mask-selection bridge step; resolve it
|
||||||
|
# immediately instead of waiting for a (non-existent) editor subscriber.
|
||||||
|
try:
|
||||||
|
awaitable.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True, "result": {"width": 100, "height": 100}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(eaar.asyncio, "wait_for", _instant_wait_for)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _FakeAiEdit:
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
calls.append(content)
|
||||||
|
return "ai_edit_image: fake", {"output": "ok", "exit_code": 0}
|
||||||
|
|
||||||
|
class _FakeGen:
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
calls.append(content)
|
||||||
|
return "generate_image: fake", {"output": "ok", "exit_code": 0}
|
||||||
|
|
||||||
|
import src.agent_tools.editor_tools as et
|
||||||
|
monkeypatch.setattr(et, "AiEditImageTool", _FakeAiEdit)
|
||||||
|
monkeypatch.setattr(et, "GenerateImageLayerTool", _FakeGen)
|
||||||
|
try:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/editor/agent",
|
||||||
|
json={"prompt": "remove the person on the left", "session": "s1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
ops = [s["op"] for s in data["steps"]]
|
||||||
|
assert ops[-1] == "ai_edit_image"
|
||||||
|
assert any(r.get("op") == "ai_edit_image" for r in data["results"])
|
||||||
|
assert calls, "the AI edit tool should have been invoked"
|
||||||
|
assert "adjustments.apply" not in ops
|
||||||
|
finally:
|
||||||
|
eaar.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_prompts_advertise_canonical_shapes():
|
||||||
|
import routes.editor_agent_routes as eaar
|
||||||
|
|
||||||
|
canonical = eaar._CANONICAL_GUIDANCE
|
||||||
|
assert "brightness-contrast" in canonical and "render-clouds" in canonical
|
||||||
|
assert canonical in eaar._SYSTEM_PROMPT
|
||||||
|
assert canonical in eaar._CHECK_SYSTEM_PROMPT
|
||||||
|
assert "_CANONICAL_GUIDANCE" in inspect.getsource(eaar.setup_editor_agent_routes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_editor_command_schema_advertises_canonical_shapes():
|
||||||
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||||
|
|
||||||
|
schema = next(s for s in FUNCTION_TOOL_SCHEMAS
|
||||||
|
if s["function"]["name"] == "run_editor_command")
|
||||||
|
desc = schema["function"]["description"]
|
||||||
|
assert "brightness-contrast" in desc and "render-clouds" in desc
|
||||||
|
assert "vignette" in desc # names the banned invented ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_photo_editor_chat_without_canvas_asks_to_open_editor():
|
||||||
|
from src import clean_agent_preview
|
||||||
|
|
||||||
|
source = inspect.getsource(clean_agent_preview.stream_preview)
|
||||||
|
assert "not open on the editor canvas" in source
|
||||||
|
assert "'photo_editor' in (getattr(turn_contract, 'active_capabilities'" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_photo_edit_without_open_editor_short_circuits(monkeypatch):
|
||||||
|
"""A photo-edit chat turn with no open editor must not run the tool loop.
|
||||||
|
|
||||||
|
Regression: offering the editor tools with no canvas made the model loop
|
||||||
|
on plan_edit/run_editor_command until the round cap and end with an empty
|
||||||
|
answer. The runtime now answers deterministically and asks the user to
|
||||||
|
open the image in the editor.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json as _json
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
import src.clean_agent_preview as module
|
||||||
|
|
||||||
|
contract = replace(
|
||||||
|
resolve_full_inventory_contract(schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()),
|
||||||
|
active_capabilities=frozenset({"photo_editor"}),
|
||||||
|
)
|
||||||
|
provider_calls = []
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stream(self, *args, **kwargs):
|
||||||
|
provider_calls.append(kwargs.get("json"))
|
||||||
|
raise AssertionError("provider must not be called when no editor is open")
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.httpx, "AsyncClient", _Client)
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
return [
|
||||||
|
chunk
|
||||||
|
async for chunk in module.stream_preview(
|
||||||
|
endpoint_url="http://test",
|
||||||
|
model="test",
|
||||||
|
headers={},
|
||||||
|
messages=[{"role": "user", "content": "make this black and white"}],
|
||||||
|
turn_contract=contract,
|
||||||
|
session_id="test",
|
||||||
|
owner="test",
|
||||||
|
disabled_tools=set(),
|
||||||
|
tool_policy=ToolPolicy(),
|
||||||
|
client_runtime_context={"web_ui_state": {"photo_editor_open": False}},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
chunks = asyncio.run(_run())
|
||||||
|
events = [
|
||||||
|
_json.loads(chunk[6:])
|
||||||
|
for chunk in chunks
|
||||||
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]")
|
||||||
|
]
|
||||||
|
|
||||||
|
assert provider_calls == []
|
||||||
|
contract_event = next(e for e in events if e.get("type") == "turn_contract")
|
||||||
|
assert "photo_editor" in contract_event["active_capabilities"]
|
||||||
|
final = next(e for e in events if e.get("type") == "final_response")
|
||||||
|
lowered = final["content"].lower()
|
||||||
|
assert "editor" in lowered and "open" in lowered
|
||||||
|
# One-click affordance contract for the chat UI.
|
||||||
|
assert final["photo_editor_action"] == {
|
||||||
|
"kind": "open_and_apply",
|
||||||
|
"prompt": "make this black and white",
|
||||||
|
}
|
||||||
|
assert not any(e.get("type") == "tool_start" for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
"""Unit tests for the shared photo-editor style guide and its server wiring.
|
||||||
|
|
||||||
|
The guide (``src/editor_style_guide.py``) is the single source of truth for
|
||||||
|
intent -> ops routing, calibrated strengths and the low-key / temperature /
|
||||||
|
portrait / ambiguity rules. It feeds both the server planner and (via
|
||||||
|
``GET /api/editor/style-guide``) the browser fallback.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import routes.editor_agent_routes as eaar
|
||||||
|
from src import editor_style_guide as guide
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_planner():
|
||||||
|
eaar.set_editor_planner(None)
|
||||||
|
yield
|
||||||
|
eaar.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(eaar.setup_editor_agent_routes())
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _ops(steps):
|
||||||
|
return [s["op"] for s in steps]
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_ops_are_in_the_server_op_catalog():
|
||||||
|
# If a new retouch/selection op is missing from OP_CATALOG, _parse_plan
|
||||||
|
# filters a valid plan step out and the edit silently disappears.
|
||||||
|
for name in guide.NEW_OP_NAMES:
|
||||||
|
assert name in eaar.OP_CATALOG
|
||||||
|
for name in ("photo.autoTone", "retouch.skin", "retouch.teeth",
|
||||||
|
"selection.face", "selection.background"):
|
||||||
|
assert name in eaar._OP_SET
|
||||||
|
|
||||||
|
|
||||||
|
def test_portrait_skin_intent_is_a_multi_step_plan():
|
||||||
|
plan = guide.fallback_plan("can you fix the skin a bit, look my best")
|
||||||
|
assert plan is not None
|
||||||
|
# No leading whole-image auto tone: it washes out the face. Go straight to a
|
||||||
|
# gentle face retouch, then clear the selection.
|
||||||
|
assert _ops(plan["steps"]) == [
|
||||||
|
"selection.face", "retouch.skin", "selection.deselect",
|
||||||
|
]
|
||||||
|
skin = plan["steps"][1]["params"]
|
||||||
|
assert 0 < skin["smooth"] <= 14
|
||||||
|
assert 0 < skin["opacity"] <= 0.45
|
||||||
|
# No global lift anywhere in the portrait chain (client never brightens skin).
|
||||||
|
assert not any("autoTone" in op or "brightness" in op or "exposure" in op
|
||||||
|
for op in _ops(plan["steps"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_portrait_intent_does_not_lead_with_auto_tone():
|
||||||
|
for prompt in ("make me look better", "fix the skin", "fix my teeth",
|
||||||
|
"smooth my complexion", "portrait touch up"):
|
||||||
|
plan = guide.fallback_plan(prompt)
|
||||||
|
assert plan is not None, prompt
|
||||||
|
assert "photo.autoTone" not in _ops(plan["steps"]), prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_teeth_intent_selects_face_then_whitens():
|
||||||
|
plan = guide.fallback_plan("fix my teeth")
|
||||||
|
assert _ops(plan["steps"]) == ["selection.face", "retouch.teeth"]
|
||||||
|
assert plan["steps"][1]["params"]["desat"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_intent_is_selection_then_masked_blur():
|
||||||
|
plan = guide.fallback_plan("blur the background / make the background nicer")
|
||||||
|
ops = _ops(plan["steps"])
|
||||||
|
assert ops == ["selection.background", "filters.applyMasked"]
|
||||||
|
assert plan["steps"][1]["params"]["id"] == "blur-gaussian"
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_sign_is_cool_negative_warm_positive():
|
||||||
|
cool = guide.fallback_plan("make it moodier")
|
||||||
|
assert cool["steps"][0]["params"] == {
|
||||||
|
"type": "white-balance", "params": {"temperature": -25}}
|
||||||
|
warm = guide.fallback_plan("make it warmer, golden summer feel")
|
||||||
|
assert warm["steps"][0]["params"]["params"]["temperature"] == 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_lighting_intent_uses_auto_tone_then_single_shadow_lift():
|
||||||
|
plan = guide.fallback_plan("fix the lighting, it's too dark")
|
||||||
|
assert _ops(plan["steps"]) == ["photo.autoTone", "adjustments.apply"]
|
||||||
|
inner = plan["steps"][1]["params"]
|
||||||
|
assert inner["type"] == "shadows-highlights"
|
||||||
|
assert inner["params"]["highlights"] <= 0
|
||||||
|
# The sanctioned low-key pair, never a stack of unspecified lifts.
|
||||||
|
assert len(plan["steps"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambiguous_prompt_asks_the_user_with_concrete_options():
|
||||||
|
plan = guide.fallback_plan("clean this up")
|
||||||
|
assert plan["steps"] == []
|
||||||
|
ask = plan["ask_user"]
|
||||||
|
assert ask["question"]
|
||||||
|
assert 2 <= len(ask["options"]) <= 3
|
||||||
|
assert all(o.get("label") for o in ask["options"])
|
||||||
|
assert guide.is_ambiguous("clean this up") is True
|
||||||
|
# A concrete defect is not ambiguous.
|
||||||
|
assert guide.is_ambiguous("fix the lighting") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_global_lifts_replaces_a_stack():
|
||||||
|
# With an auto tone present, extra brightness/exposure lifts are dropped
|
||||||
|
# (the auto tone is the single sanctioned lift).
|
||||||
|
with_auto = [
|
||||||
|
{"op": "photo.autoTone", "params": {}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "brightness-contrast", "params": {"brightness": 30}}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
||||||
|
]
|
||||||
|
out = guide.collapse_global_lifts(with_auto)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["op"] == "photo.autoTone"
|
||||||
|
# Without an auto tone, a stack collapses to one safe shadows-highlights.
|
||||||
|
no_auto = [
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "brightness-contrast", "params": {"brightness": 30}}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
||||||
|
]
|
||||||
|
out2 = guide.collapse_global_lifts(no_auto)
|
||||||
|
assert len(out2) == 1
|
||||||
|
assert out2[0]["params"]["type"] == "shadows-highlights"
|
||||||
|
assert out2[0]["params"]["params"]["highlights"] <= 0
|
||||||
|
# The sanctioned low-key pair (auto tone + one shadows-highlights) is kept.
|
||||||
|
pair = [
|
||||||
|
{"op": "photo.autoTone", "params": {}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "shadows-highlights",
|
||||||
|
"params": {"shadows": 30, "highlights": -15}}},
|
||||||
|
]
|
||||||
|
assert guide.collapse_global_lifts(pair) == pair
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_global_lifts_clamps_a_single_brightness_lift():
|
||||||
|
steps = [{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "brightness-contrast", "params": {"brightness": 80}}}]
|
||||||
|
out = guide.collapse_global_lifts(steps)
|
||||||
|
assert out[0]["params"]["params"]["brightness"] <= 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_plan_parse_collapses_lifts_and_keeps_new_ops():
|
||||||
|
raw = (
|
||||||
|
'{"reply": "x", "steps": ['
|
||||||
|
'{"op": "retouch.skin", "params": {"smooth": 20}},'
|
||||||
|
'{"op": "photo.autoTone", "params": {}},'
|
||||||
|
'{"op": "adjustments.apply", "params": {"type": "exposure", "params": {"exposure": 25}}}'
|
||||||
|
']}'
|
||||||
|
)
|
||||||
|
parsed = eaar._parse_plan(raw)
|
||||||
|
ops = _ops(parsed["steps"])
|
||||||
|
assert "retouch.skin" in ops
|
||||||
|
# Every global lift that trails retouch.skin is dropped (the client guard
|
||||||
|
# "never brighten skin"), so nothing but the retouch step remains.
|
||||||
|
assert ops == ["retouch.skin"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_agent_returns_ask_user_for_ambiguous_prompt(client):
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: '{"reply": "ok", "steps": []}')
|
||||||
|
resp = client.post("/api/editor/agent", json={"prompt": "clean this up"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body.get("ask_user_pending") is True
|
||||||
|
assert body["steps"] == []
|
||||||
|
assert 2 <= len(body["ask_user"]["options"]) <= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_plan_returns_ask_user_for_ambiguous_prompt(client):
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: None)
|
||||||
|
resp = client.post("/api/editor/plan", json={"prompt": "clean this up"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["steps"] == []
|
||||||
|
assert body.get("ask_user")
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_guide_endpoint_serves_the_shared_source(client):
|
||||||
|
resp = client.get("/api/editor/style-guide")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["version"] == guide.STYLE_GUIDE_VERSION
|
||||||
|
assert {o["op"] for o in data["new_ops"]} == set(guide.NEW_OP_NAMES)
|
||||||
|
assert data["intents"]
|
||||||
|
assert "temperature" in data["rules"]["temperature_sign"].lower() or \
|
||||||
|
"negative" in data["rules"]["temperature_sign"]
|
||||||
|
# The prompt guidance states the sign rule explicitly.
|
||||||
|
assert "NEGATIVE" in data["guidance"] and "POSITIVE" in data["guidance"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompts_embed_the_style_guidance():
|
||||||
|
assert guide.guidance() in eaar._SYSTEM_PROMPT
|
||||||
|
assert guide.guidance() in eaar._CHECK_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt", [
|
||||||
|
"make it pretty", "make it pop", "professional", "film look",
|
||||||
|
])
|
||||||
|
def test_generic_look_intent_is_a_decisive_multi_step_plan(prompt):
|
||||||
|
plan = guide.fallback_plan(prompt)
|
||||||
|
assert plan is not None
|
||||||
|
ops = _ops(plan["steps"])
|
||||||
|
assert ops[0] == "photo.autoTone"
|
||||||
|
assert len(ops) >= 3
|
||||||
|
# A visible vibrance lift and a local-contrast filter are present.
|
||||||
|
assert any(s["op"] == "adjustments.apply" and s["params"].get("type") == "vibrance"
|
||||||
|
for s in plan["steps"])
|
||||||
|
assert any(s["op"] == "filters.apply" and s["params"].get("id") == "clarity"
|
||||||
|
for s in plan["steps"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_intent_never_large_exposure_lift():
|
||||||
|
plan = guide.fallback_plan("restore this old photo")
|
||||||
|
assert plan is not None
|
||||||
|
ops = _ops(plan["steps"])
|
||||||
|
assert ops[0] == "photo.autoTone"
|
||||||
|
assert "filters.apply" in ops # sharpen
|
||||||
|
# Colour recovery present.
|
||||||
|
assert any(s["op"] == "adjustments.apply" and s["params"].get("type") == "white-balance"
|
||||||
|
for s in plan["steps"])
|
||||||
|
# No exposure lift at all, and the contrast lift keeps brightness neutral.
|
||||||
|
assert not any(s["params"].get("type") == "exposure" for s in plan["steps"])
|
||||||
|
for s in plan["steps"]:
|
||||||
|
if s["params"].get("type") == "brightness-contrast":
|
||||||
|
assert s["params"]["params"].get("brightness", 0) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_strengthen_promotes_a_timid_plan_to_the_recipe():
|
||||||
|
timid = [{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "brightness-contrast", "params": {"brightness": 8}}}]
|
||||||
|
promoted = guide.strengthen("make it pop", timid)
|
||||||
|
assert promoted != timid
|
||||||
|
assert _ops(promoted)[0] == "photo.autoTone"
|
||||||
|
assert len(promoted) >= 3
|
||||||
|
# A prompt with no recipe leaves the plan untouched.
|
||||||
|
assert guide.strengthen("nonsense prompt zzz", timid) == timid
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_guards_removes_trailing_lift_after_skin():
|
||||||
|
steps = [
|
||||||
|
{"op": "selection.face", "params": {}},
|
||||||
|
{"op": "retouch.skin", "params": {"smooth": 24}},
|
||||||
|
{"op": "photo.autoTone", "params": {}},
|
||||||
|
]
|
||||||
|
guarded = guide.enforce_guards(steps)
|
||||||
|
assert _ops(guarded) == ["selection.face", "retouch.skin"]
|
||||||
|
stacked = [
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "brightness-contrast", "params": {"brightness": 20}}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
||||||
|
]
|
||||||
|
collapsed = guide.enforce_guards(stacked)
|
||||||
|
assert len(collapsed) == 1
|
||||||
|
assert collapsed[0]["params"]["type"] == "shadows-highlights"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_route_follows_the_recipe_for_generic_look(client):
|
||||||
|
import json as _json
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: _json.dumps({
|
||||||
|
"reply": "ok",
|
||||||
|
"steps": [{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "brightness-contrast", "params": {"brightness": 8}}}],
|
||||||
|
}))
|
||||||
|
resp = client.post("/api/editor/plan", json={"prompt": "make it pop"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["steps"][0]["op"] == "photo.autoTone"
|
||||||
|
assert len(data["steps"]) >= 3
|
||||||
|
assert data["planner"].endswith("+guide")
|
||||||
|
|
||||||
|
|
||||||
|
def test_guidance_documents_look_and_restore_recipes():
|
||||||
|
text = guide.guidance()
|
||||||
|
low = text.lower()
|
||||||
|
assert "clarity" in low and "vignette" in low
|
||||||
|
assert "vibrance" in low
|
||||||
|
assert "restore" in low and "exposure" in low
|
||||||
|
assert "autoTone" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_special_format_crops_and_resizes():
|
||||||
|
special = eaar._plan_special(
|
||||||
|
"make a 1080x1350 Instagram post from this", {"width": 900, "height": 600}, False)
|
||||||
|
assert special is not None
|
||||||
|
steps, reply, note = special
|
||||||
|
assert _ops(steps) == ["document.crop", "document.resizeCanvas"]
|
||||||
|
assert "1080" in reply and "1350" in reply
|
||||||
|
assert set(steps[0]["params"]) >= {"x", "y", "width", "height"}
|
||||||
|
assert steps[1]["params"]["width"] == 1080 and steps[1]["params"]["height"] == 1350
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_special_text_add_with_shadow_is_not_generative():
|
||||||
|
assert eaar._generative_kind("add a title TOKYO with a drop shadow") is None
|
||||||
|
special = eaar._plan_special("add a title TOKYO with a drop shadow", {}, False)
|
||||||
|
assert special is not None
|
||||||
|
steps, reply, note = special
|
||||||
|
assert _ops(steps) == ["text.add", "layers.addStyle"]
|
||||||
|
assert steps[0]["params"]["text"] == "TOKYO"
|
||||||
|
assert steps[1]["params"]["type"] == "drop-shadow"
|
||||||
|
assert "drop shadow" in reply.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_special_extend_resizes_without_provider_generative_with():
|
||||||
|
steps, reply, note = eaar._plan_special(
|
||||||
|
"extend the canvas to 16:9", {"width": 900, "height": 600}, False)
|
||||||
|
assert _ops(steps) == ["document.resizeCanvas"]
|
||||||
|
assert "provider" in note.lower()
|
||||||
|
steps2, _, note2 = eaar._plan_special(
|
||||||
|
"extend the canvas to 16:9", {"width": 900, "height": 600}, True)
|
||||||
|
assert _ops(steps2) == ["ai.generativeExpand"]
|
||||||
|
assert note2 == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_special_psd_export():
|
||||||
|
special = eaar._plan_special("save as PSD with layers", {}, False)
|
||||||
|
assert special is not None and _ops(special[0]) == ["document.savePsd"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_replacement_is_generative_not_blur():
|
||||||
|
assert eaar._generative_kind("put her on a beach") == "replace"
|
||||||
|
assert eaar._generative_kind("remove the background and put her on a beach") == "replace"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verified_reply_corrects_a_false_size_claim():
|
||||||
|
corrected = eaar._verified_reply(
|
||||||
|
"make a 1080x1350 Instagram post",
|
||||||
|
[{"op": "history.mark", "params": {}}],
|
||||||
|
[{"ok": True}],
|
||||||
|
{"width": 600, "height": 600},
|
||||||
|
"Exported your 900x600 as a 1080x1350 Instagram post.",
|
||||||
|
)
|
||||||
|
assert "600" in corrected
|
||||||
|
assert "still" in corrected.lower() or "didn't apply" in corrected.lower()
|
||||||
|
passed = eaar._verified_reply(
|
||||||
|
"make a 1080x1350 Instagram post",
|
||||||
|
[{"op": "document.crop", "params": {}}, {"op": "document.resizeCanvas", "params": {}}],
|
||||||
|
[{"ok": True}, {"ok": True}],
|
||||||
|
{"width": 1080, "height": 1350},
|
||||||
|
"Setting the canvas to 1080\u00d71350.",
|
||||||
|
)
|
||||||
|
assert "1080" in passed and "1350" in passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_endpoint_returns_deterministic_format_steps(client, monkeypatch):
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: {"reply": "Exporting.", "steps": []})
|
||||||
|
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: False)
|
||||||
|
resp = client.post("/api/editor/plan", json={
|
||||||
|
"prompt": "make a 1080x1350 Instagram post",
|
||||||
|
"state": {"width": 900, "height": 600},
|
||||||
|
})
|
||||||
|
data = resp.json()
|
||||||
|
assert data["planner"] == "deterministic"
|
||||||
|
assert _ops(data["steps"])[:2] == ["document.crop", "document.resizeCanvas"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_guide_serves_format_text_psd_rules():
|
||||||
|
rules = guide.as_json()["rules"]
|
||||||
|
assert "format" in rules and "text" in rules and "psd" in rules
|
||||||
|
assert guide.STYLE_GUIDE_VERSION >= 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quick Edit coverage: structural ops (server planner gaps) ──────────
|
||||||
|
# Every prompt that failed the live Quick Edit coverage audit because the
|
||||||
|
# server planner emitted a generic adjustments/transform step instead of the
|
||||||
|
# exact structural op the browser supports.
|
||||||
|
_STRUCTURAL_PROMPTS = [
|
||||||
|
("fill the white area with red", "fill.flood"), # 1
|
||||||
|
("flood fill the sky with blue", "fill.flood"), # 2
|
||||||
|
("make the text bigger", "text.edit"), # 3
|
||||||
|
("make the title red", "text.edit"), # 4
|
||||||
|
("add a colour overlay", "layers.addStyle"), # 8
|
||||||
|
("align the logo to the centre", "layers.align"), # 9
|
||||||
|
("align the layer to the left", "layers.align"), # 10
|
||||||
|
("rotate only this layer 90 degrees", "layer.rotate"), # 11
|
||||||
|
("flip this layer horizontally", "layer.flip"), # 12
|
||||||
|
("rotate the whole image 90 degrees", "document.rotate"), # 13
|
||||||
|
("flip the canvas vertically", "document.flip"), # 14
|
||||||
|
("copy the selection", "selection.copy"), # 19
|
||||||
|
("paste", "selection.paste"), # 20
|
||||||
|
("flatten the image", "layers.flatten"), # 40
|
||||||
|
("merge the layer down", "layers.mergeDown"), # 41
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt,expected_op", _STRUCTURAL_PROMPTS)
|
||||||
|
def test_structural_plan_routes_to_the_exact_op(prompt, expected_op):
|
||||||
|
plan = guide.structural_plan(prompt)
|
||||||
|
assert plan is not None, prompt
|
||||||
|
assert plan["planner"] == "deterministic"
|
||||||
|
assert expected_op in _ops(plan["steps"]), (prompt, _ops(plan["steps"]))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt,expected_op", _STRUCTURAL_PROMPTS)
|
||||||
|
def test_plan_special_emits_structural_ops(prompt, expected_op):
|
||||||
|
# The server override path (_plan_special) must return the structural step,
|
||||||
|
# otherwise a wrong model plan suppresses the browser fallback.
|
||||||
|
special = eaar._plan_special(prompt, {"width": 900, "height": 600}, True)
|
||||||
|
assert special is not None, prompt
|
||||||
|
steps, reply, _ = special
|
||||||
|
assert expected_op in _ops(steps), (prompt, _ops(steps))
|
||||||
|
assert reply
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_ops_are_in_the_server_op_catalog():
|
||||||
|
for name in ("fill.flood", "selection.fill", "text.edit", "layers.align",
|
||||||
|
"layer.rotate", "layer.flip", "document.rotate", "document.flip",
|
||||||
|
"selection.copy", "selection.paste", "layers.flatten",
|
||||||
|
"layers.mergeDown"):
|
||||||
|
assert name in eaar._OP_SET, name
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_plan_ignores_non_structural_intents():
|
||||||
|
for prompt in ("make it brighter", "blur the background", "make it punchy",
|
||||||
|
"restore this old photo", "smooth the skin", "sharpen it",
|
||||||
|
"add the title \"Summer\"", "duplicate this layer"):
|
||||||
|
assert guide.structural_plan(prompt) is None, prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_params_match_the_client():
|
||||||
|
# Canonical params must match static/js/editor/galleryEditor.js control ops.
|
||||||
|
fill = guide.structural_plan("fill the white area with red")["steps"][0]
|
||||||
|
assert fill["op"] == "fill.flood" and fill["params"]["color"] == "#ff0000"
|
||||||
|
sel = guide.structural_plan("fill the selection with blue")["steps"][0]
|
||||||
|
assert sel["op"] == "selection.fill" and sel["params"]["color"] == "#0000ff"
|
||||||
|
align = guide.structural_plan("align the layer to the left")["steps"][0]
|
||||||
|
assert align["params"]["mode"] == "left"
|
||||||
|
rot = guide.structural_plan("rotate only this layer 90 degrees")["steps"][0]
|
||||||
|
assert rot["params"]["degrees"] == 90
|
||||||
|
flip = guide.structural_plan("flip the canvas vertically")["steps"][0]
|
||||||
|
assert flip["op"] == "document.flip" and flip["params"]["axis"] == "v"
|
||||||
|
overlay = guide.structural_plan("add a colour overlay")["steps"][0]
|
||||||
|
assert overlay["params"]["type"] == "color-overlay"
|
||||||
|
edit = guide.structural_plan("make the text bigger")["steps"][0]
|
||||||
|
assert edit["op"] == "text.edit" and edit["params"]["size"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_route_returns_structural_steps(client):
|
||||||
|
resp = client.post("/api/editor/plan", json={
|
||||||
|
"prompt": "flatten the image",
|
||||||
|
"state": {"width": 900, "height": 600},
|
||||||
|
})
|
||||||
|
data = resp.json()
|
||||||
|
assert data["planner"] == "deterministic"
|
||||||
|
assert "layers.flatten" in _ops(data["steps"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_guide_serves_structural_rule():
|
||||||
|
rules = guide.as_json()["rules"]
|
||||||
|
assert "structural" in rules
|
||||||
|
assert guide.STYLE_GUIDE_VERSION >= 5
|
||||||
|
|
||||||
|
|
||||||
|
# ── Targeted recolour + named reference looks ──────────────────────────
|
||||||
|
|
||||||
|
_RECOLOR_CASES = [
|
||||||
|
("make only the red car blue", "red"),
|
||||||
|
("recolour the red rock to teal", "red"),
|
||||||
|
("change the yellow car to red", "yellow"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt,source", _RECOLOR_CASES)
|
||||||
|
def test_targeted_recolor_selects_object_then_masked_hue(prompt, source):
|
||||||
|
plan = guide.recolor_plan(prompt)
|
||||||
|
assert plan is not None
|
||||||
|
assert _ops(plan["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
||||||
|
sel, adj = plan["steps"]
|
||||||
|
assert sel["params"]["text"] # object description for the mask
|
||||||
|
assert sel["params"]["fallbackColor"] == guide.color_for_name(source)
|
||||||
|
assert adj["params"]["mode"] == "hue-saturation"
|
||||||
|
assert "hue" in adj["params"] and isinstance(adj["params"]["hue"], int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recolor_never_emits_selection_fill_or_a_global_tint():
|
||||||
|
for prompt, _ in _RECOLOR_CASES:
|
||||||
|
ops = _ops(guide.recolor_plan(prompt)["steps"])
|
||||||
|
# A flat fill destroys shading; a whole-image tint is not targeted.
|
||||||
|
assert "selection.fill" not in ops
|
||||||
|
assert "adjustments.apply" not in ops # exact op, not applyMasked
|
||||||
|
|
||||||
|
|
||||||
|
def test_targeted_recolor_hue_delta_is_source_to_target():
|
||||||
|
# red (hue 0) -> blue (hue 240): shortest rotation is -120 degrees.
|
||||||
|
plan = guide.recolor_plan("make only the red car blue")
|
||||||
|
assert plan["steps"][1]["params"]["hue"] == -120
|
||||||
|
|
||||||
|
|
||||||
|
def test_recolor_masked_hue_carries_source_hue():
|
||||||
|
# red has hue 0; the client uses sourceHue as the rotation center so
|
||||||
|
# neighbouring colours (chrome, windows, highlights) keep their own hue.
|
||||||
|
plan = guide.recolor_plan("make only the red car blue")
|
||||||
|
assert plan["steps"][1]["params"]["sourceHue"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_guards_drops_global_lift_after_masked_recolor():
|
||||||
|
# A recolour keeps shading; a later whole-image tone lift would tint the
|
||||||
|
# rest of the photo, so enforce_guards must drop global lifts after it.
|
||||||
|
steps = [
|
||||||
|
{"op": "selection.samText", "params": {"text": "red car"}},
|
||||||
|
{"op": "adjustments.applyMasked",
|
||||||
|
"params": {"mode": "hue-saturation", "hue": -120, "sourceHue": 0}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "brightness-contrast", "params": {"brightness": 20}}},
|
||||||
|
{"op": "photo.autoTone", "params": {}},
|
||||||
|
]
|
||||||
|
assert _ops(guide.enforce_guards(steps)) == ["selection.samText", "adjustments.applyMasked"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_guards_drops_whole_image_grade_after_masked_recolor():
|
||||||
|
# Not just tone "lifts": ANY whole-image adjustment (vibrance/saturation/
|
||||||
|
# autoTone) after a masked recolour re-grades the photo and undoes the
|
||||||
|
# "only this object" intent, so enforce_guards must drop them all.
|
||||||
|
steps = [
|
||||||
|
{"op": "selection.samText", "params": {"text": "red car"}},
|
||||||
|
{"op": "adjustments.applyMasked",
|
||||||
|
"params": {"mode": "hue-saturation", "hue": -120, "sourceHue": 0}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "vibrance", "params": {"vibrance": 20}}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "hue-saturation", "params": {"saturation": 12}}},
|
||||||
|
{"op": "photo.autoTone", "params": {}},
|
||||||
|
]
|
||||||
|
assert _ops(guide.enforce_guards(steps)) == ["selection.samText", "adjustments.applyMasked"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_guards_keeps_grades_when_there_is_no_recolor():
|
||||||
|
# A recipe/look (no masked recolour) still keeps its whole-image grades.
|
||||||
|
steps = [
|
||||||
|
{"op": "photo.autoTone", "params": {}},
|
||||||
|
{"op": "adjustments.apply", "params": {"type": "vibrance", "params": {"vibrance": 18}}},
|
||||||
|
{"op": "adjustments.apply",
|
||||||
|
"params": {"type": "brightness-contrast", "params": {"brightness": 6, "contrast": -10}}},
|
||||||
|
]
|
||||||
|
assert _ops(guide.enforce_guards(steps)) == ["photo.autoTone", "adjustments.apply"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recolor_to_white_uses_the_image_model_not_a_hue_shift():
|
||||||
|
# A white/black/grey target cannot be reached by a hue rotation.
|
||||||
|
plan = guide.recolor_plan("make the red car white")
|
||||||
|
assert plan is not None
|
||||||
|
assert _ops(plan["steps"]) == ["selection.samText", "ai_edit_image"]
|
||||||
|
assert plan["steps"][1]["params"]["mode"] == "masked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recolor_with_single_colour_uses_masked_image_model():
|
||||||
|
plan = guide.recolor_plan("make the flowers bright pink")
|
||||||
|
assert plan is not None
|
||||||
|
assert _ops(plan["steps"]) == ["selection.samText", "ai_edit_image"]
|
||||||
|
assert plan["steps"][0]["params"]["text"] == "flowers"
|
||||||
|
params = plan["steps"][1]["params"]
|
||||||
|
assert params["mode"] == "masked"
|
||||||
|
assert params["steps"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_recolor_does_not_hijack_unrelated_colour_prompts():
|
||||||
|
for prompt in ("make it blue", "fill the white area with red",
|
||||||
|
"make the title red", "make this black and white",
|
||||||
|
"blur the background"):
|
||||||
|
assert guide.recolor_plan(prompt) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt", [
|
||||||
|
"make it look like a Wes Anderson film",
|
||||||
|
"make it look like a film noir",
|
||||||
|
"make it look like vintage Kodachrome",
|
||||||
|
])
|
||||||
|
def test_named_recipe_looks_are_adjustment_stacks(prompt):
|
||||||
|
plan = guide.look_plan(prompt)
|
||||||
|
assert plan is not None
|
||||||
|
ops = _ops(plan["steps"])
|
||||||
|
assert ops and "ai_edit_image" not in ops
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt", [
|
||||||
|
"make it look like cyberpunk",
|
||||||
|
"make it look like a Studio Ghibli film",
|
||||||
|
])
|
||||||
|
def test_strong_named_looks_use_instruction_mode(prompt):
|
||||||
|
plan = guide.look_plan(prompt)
|
||||||
|
assert plan is not None
|
||||||
|
assert _ops(plan["steps"]) == ["ai_edit_image"]
|
||||||
|
assert plan["steps"][0]["params"]["mode"] == "instruction"
|
||||||
|
assert plan["steps"][0]["params"]["steps"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_plan_dispatches_looks_and_recolor():
|
||||||
|
wes = guide.structural_plan("make it look like a Wes Anderson film")
|
||||||
|
assert "adjustments.apply" in _ops(wes["steps"])
|
||||||
|
recolor = guide.structural_plan("make only the red car blue")
|
||||||
|
assert _ops(recolor["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_looks_do_not_hijack_generative_lookalikes():
|
||||||
|
for prompt in ("make it look snowy", "make it look like summer"):
|
||||||
|
assert guide.look_plan(prompt) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_route_returns_targeted_recolor(client):
|
||||||
|
resp = client.post("/api/editor/plan", json={
|
||||||
|
"prompt": "make only the red car blue",
|
||||||
|
"state": {"width": 900, "height": 600},
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["planner"] == "deterministic"
|
||||||
|
assert _ops(data["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
||||||
|
assert "selection.fill" not in _ops(data["steps"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_route_returns_named_look(client):
|
||||||
|
resp = client.post("/api/editor/plan", json={
|
||||||
|
"prompt": "make it look like a Wes Anderson film",
|
||||||
|
"state": {"width": 900, "height": 600},
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["planner"] == "deterministic"
|
||||||
|
assert "adjustments.apply" in _ops(data["steps"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_guide_serves_recolor_and_looks_rules():
|
||||||
|
body = guide.as_json()
|
||||||
|
assert "recolor" in body["rules"]
|
||||||
|
assert "looks" in body["rules"]
|
||||||
|
assert {lk["id"] for lk in body["looks"]} >= {
|
||||||
|
"wes_anderson", "film_noir", "cyberpunk", "vintage_kodachrome",
|
||||||
|
"studio_ghibli",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Graceful-shutdown signalling tests (HARNESS-owned).
|
||||||
|
|
||||||
|
SSE / streaming responses never complete on their own, so uvicorn's graceful
|
||||||
|
shutdown would wait for an open browser stream up to the whole
|
||||||
|
``timeout_graceful_shutdown`` window. src.shutdown is a process-wide flag that
|
||||||
|
the SIGTERM/SIGINT handler flips and every streaming generator checks, so a
|
||||||
|
restart ends open streams promptly while ordinary in-flight requests still
|
||||||
|
finish.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src import shutdown
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_shutdown_is_sticky_and_resettable():
|
||||||
|
shutdown.reset()
|
||||||
|
try:
|
||||||
|
assert shutdown.is_shutting_down() is False
|
||||||
|
shutdown.request_shutdown()
|
||||||
|
assert shutdown.is_shutting_down() is True
|
||||||
|
# idempotent
|
||||||
|
shutdown.request_shutdown()
|
||||||
|
assert shutdown.is_shutting_down() is True
|
||||||
|
finally:
|
||||||
|
shutdown.reset()
|
||||||
|
assert shutdown.is_shutting_down() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_installs_its_own_shutdown_handler():
|
||||||
|
src = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||||
|
# This uvicorn version has no install_signal_handlers kwarg, so app.py wraps
|
||||||
|
# server.handle_exit (which capture_signals wires SIGINT/SIGTERM to).
|
||||||
|
assert "install_signal_handlers=False" not in src
|
||||||
|
assert "server.handle_exit = _handle_shutdown_signal" in src
|
||||||
|
assert "_shutdown.request_shutdown()" in src
|
||||||
|
assert "server.run()" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_generators_check_the_shutdown_flag():
|
||||||
|
# editor bridge SSE + chat stream/resume + research + email events + shell.
|
||||||
|
for rel in (
|
||||||
|
"routes/editor_agent_routes.py",
|
||||||
|
"routes/chat_routes.py",
|
||||||
|
"routes/research/research_routes.py",
|
||||||
|
"routes/email/email_routes.py",
|
||||||
|
"routes/shell_routes.py",
|
||||||
|
):
|
||||||
|
src = (ROOT / rel).read_text(encoding="utf-8")
|
||||||
|
assert "_shutdown.is_shutting_down()" in src, rel
|
||||||
Reference in New Issue
Block a user