Merge current dev and refresh PR #6739 from the 7011 integration

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:17 +00:00
157 changed files with 8685 additions and 1089 deletions
+1 -1
View File
@@ -15204,7 +15204,7 @@ _ADMIN_TOOLS = {
"manage_documents", "manage_settings", "create_session", "list_sessions",
"send_to_session", "search_chats", "pipeline", "ask_teacher", "list_models",
"list_cached_models", "list_downloads", "list_cookbook_servers",
"list_serve_presets", "list_served_models",
"list_serve_presets", "list_served_models", "tail_serve_output", "search_hf_models",
}
def _build_base_prompt(
+15 -5
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass, replace
from functools import wraps
from inspect import signature
import json
import os
from pathlib import Path
import re
from uuid import uuid4
@@ -140,11 +141,20 @@ class RequestAuthority:
raise ValueError("Malformed request authority")
if self.resource_roots is None:
roots = ()
if self.workspace:
try:
roots = (FilesystemRoot.seal(self.workspace, owner=self.owner),)
except (OSError, ValueError, RuntimeError):
pass # An unresolved workspace grants no filesystem root.
# With no workspace, bash/python run in the agent workspace
# (agent_cwd), so the file tools get that same root (#6679).
# Imported here: authority -> execution is a new edge, and
# tool_execution imports this module at load time.
from src import tool_execution
try:
root = self.workspace
if not root:
# Vetted like agent_cwd(): a symlinked agent workspace
# raises and so grants no root.
root = tool_execution.default_agent_workspace()
roots = (FilesystemRoot.seal(root, owner=self.owner),)
except (OSError, ValueError, RuntimeError):
pass # An unresolved workspace grants no filesystem root.
object.__setattr__(self, "resource_roots", roots)
if (not isinstance(self.resource_roots, tuple)
or any(not isinstance(r, FilesystemRoot) or (r.owner and r.owner != self.owner)
+5
View File
@@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
from .bg_job_tools import ManageBgJobsTool
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
from .admin_tools import (
ADMIN_TOOL_HANDLERS,
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
"list_sessions": ListSessionsTool().execute,
"send_to_session": SendToSessionTool().execute,
"manage_session": ManageSessionTool().execute,
"plan_edit": PlanEditTool().execute,
"run_editor_command": RunEditorCommandTool().execute,
"get_editor_screenshot": GetEditorScreenshotTool().execute,
"ai_edit_image": AiEditImageTool().execute,
}
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
+15 -1
View File
@@ -38,11 +38,25 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
elif action == "add":
import uuid as _uuid
from src.url_safety import check_outbound_url
name = args.get("name", "")
base_url = args.get("base_url", "")
api_key = args.get("api_key", "")
if not base_url:
return {"error": "base_url is required", "exit_code": 1}
# SSRF hardening: validate the URL before storing it. Every entry
# point that accepts an external base_url must run this guard.
# The HTTP route (POST /api/model-endpoints) gets the same guard
# in #6316; this tool bypasses that route entirely, so it needs
# its own validation. A prompt-injection payload smuggled into a
# skill, note, fetched page, or email could instruct the model to
# register a cloud-metadata or internal-network endpoint.
ok, reason = check_outbound_url(
base_url,
block_private=os.getenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
return {"error": f"Rejected endpoint URL: {reason}", "exit_code": 1}
eid = str(_uuid.uuid4())[:8]
from datetime import datetime
ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url,
@@ -75,7 +89,7 @@ async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict
return {"error": f"Unknown action: {action}", "exit_code": 1}
except Exception as e:
logger.error(f"manage_endpoints error: {e}")
return {"error": str(e), "exit_code": 1}
return {"error": "An internal error occurred while managing endpoints", "exit_code": 1}
finally:
db.close()
+72 -20
View File
@@ -965,7 +965,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
elif action == "open_panel":
# Open a top-level panel/modal: documents/library, gallery,
# calendar, email, sessions, notes, memories, skills, settings, theme, cookbook.
# calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook.
panel = parts[1].lower() if len(parts) > 1 else ""
view = ""
view_label = ""
@@ -993,6 +993,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
"note": "notes",
"todo": "notes",
"todos": "notes",
"tasks": "tasks",
"task": "tasks",
"memories": "memories",
"memory": "memories",
"brain": "memories",
@@ -1010,7 +1012,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
}
target = _panel_aliases.get(panel)
if not target:
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, memories, skills, settings, theme, cookbook."}
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook."}
if target == "cookbook":
cookbook_views = {
"models": ("Search", "models"), "model": ("Search", "models"),
@@ -1155,7 +1157,50 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
# Image generation
# ---------------------------------------------------------------------------
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
# Message used whenever a generative image edit (object add/remove/replace,
# image generation) is requested but no image provider is configured. The
# photo-editor agent and the image tools surface this verbatim so the user gets
# a plain, actionable explanation instead of a silent substitute edit.
IMAGE_PROVIDER_REQUIRED_MESSAGE = (
"Removing/adding objects needs an image generation provider; enable one in "
"Settings > Models."
)
def image_editing_provider_available(owner: Optional[str] = None) -> bool:
"""True when an enabled, owner-visible image endpoint can serve AI edits.
Generative operations (object add/remove/replace, image generation) require
a configured image model. Local transforms (remove-bg, upscale, denoise,
enhance-face) do not, but the photo-editor agent gates the whole generative
request on this so it never falls back to deterministic approximations.
"""
try:
from src.settings import load_settings
if not load_settings().get("image_gen_enabled", True):
return False
except Exception:
pass
try:
from src.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True, # noqa: E712
ModelEndpoint.model_type == "image",
)
q = owner_filter(q, ModelEndpoint, owner)
return q.first() is not None
finally:
db.close()
except Exception:
return False
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
"""Generate an image using an image-capable model (e.g. gpt-image-1).
Content format:
@@ -1255,6 +1300,14 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
async def _resolve_image_model(model_name: str):
def _call():
# Try a plain by-name lookup first. Gateway endpoints (OpenRouter)
# register as `llm` yet serve image models, and the image-typed
# query's last-resort fallback would otherwise hijack a cloud model
# onto the local image endpoint (gpt-5-image -> qwen-image-edit).
try:
return _resolve_model(model_name, owner=owner)
except ValueError:
pass
try:
return _resolve_model(model_name, owner=owner, model_type="image")
except TypeError as exc:
@@ -1314,7 +1367,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
else:
payload["quality"] = "medium"
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={prompt[:80]}")
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={'[private]' if incognito else prompt[:80]}")
try:
# GPT image models can take 30-120s+ depending on quality
@@ -1365,18 +1418,22 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
logger.warning(f"Failed to save gallery record: {_ge}")
return ""
# GPT image models always return b64_json; DALL-E may return url
if img.get("b64_json"):
def _store_image(image_bytes):
if incognito:
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
img_path.write_bytes(image_bytes)
if owner:
from src.generated_images import register_generated_image
register_generated_image(filename, owner)
image_url = f"/api/generated-image/{filename}"
image_id = _save_to_gallery(filename)
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
# Private results stay in the response, never in the gallery or on disk.
if img.get("b64_json"):
image_url, image_id = _store_image(base64.b64decode(img['b64_json']))
elif img.get("url"):
# Download external URL and save locally (DALL-E returns temp URLs)
@@ -1390,16 +1447,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
try:
dl_resp = httpx.get(result_url, timeout=60)
if dl_resp.status_code == 200:
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
img_path.write_bytes(dl_resp.content)
if owner:
from src.generated_images import register_generated_image
register_generated_image(filename, owner)
image_url = f"/api/generated-image/{filename}"
image_id = _save_to_gallery(filename)
image_url, image_id = _store_image(dl_resp.content)
else:
image_url = result_url # fallback to external URL
except Exception as _dl_e:
@@ -1436,6 +1484,7 @@ async def do_edit_image(
size: str = "auto",
quality: str = "medium",
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
incognito: bool = False,
) -> Dict:
"""Edit an uploaded image using the configured image endpoint."""
import base64
@@ -1509,7 +1558,7 @@ async def do_edit_image(
request_id = uuid.uuid4().hex
payload["request_id"] = request_id
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, '[private]' if incognito else path.name, '[private]' if incognito else prompt[:80])
def _save_edited_image_to_gallery(filename: str) -> str:
try:
@@ -1539,6 +1588,9 @@ async def do_edit_image(
from PIL import Image
with Image.open(BytesIO(image_bytes)) as output:
size = f"{output.width}x{output.height}"
image_mime = Image.MIME.get(output.format, 'image/png')
if incognito:
return 'data:' + image_mime + ';base64,' + base64.b64encode(image_bytes).decode('ascii'), ''
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
+13 -13
View File
@@ -1,8 +1,8 @@
"""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:
runs on an explicitly configured local vision model when one is reachable,
with the configured chat model as a fallback. This module owns:
* the local model base URLs / model id (settings-overridable),
* an ordered candidate list (per-request ``agent_model`` override, then the
@@ -30,18 +30,12 @@ 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",
]
# Local endpoints are opt-in through settings or ODYSSEUS_EDITOR_AGENT_BASES.
DEFAULT_LOCAL_BASES = []
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_BASE = ""
DEFAULT_CAPTION_MODEL = "Qwen/Qwen3.5-9B"
# Served-model-id cache per base (populated on first use). The local servers 400
@@ -278,6 +272,8 @@ async def caption_image(data_url: str, owner: Optional[str] = None, timeout: flo
}
]
base = caption_base()
if not base:
return None
model = await _resolve_model(base, caption_model(), local_model())
return await _post_chat(base, model, messages, None, timeout, max_tokens=200)
@@ -320,6 +316,9 @@ async def self_check(
return {"done": True, "issues": [], "retry_hint": "",
"reason": "unavailable", "text": "DONE"}
bases = local_bases() or list(DEFAULT_LOCAL_BASES)
if not bases:
return {"done": True, "issues": [], "retry_hint": "",
"reason": "unavailable", "text": "DONE"}
base = bases[0]
mdl = await _resolve_model(base, str(model or local_model()), local_model())
messages = [{
@@ -482,8 +481,9 @@ def _probe_worker() -> None:
_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])
if cap:
_PROBE[cap] = probe_models(cap)
logger.info("editor agent caption base %s reachable=%s", cap, _PROBE[cap])
def startup_probe() -> None:
+23 -16
View File
@@ -126,9 +126,8 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
"email": row["account_email"], "default": not accounts})
return {
"accounts": list(accounts.values()),
"output": "\n".join(f"{row['name']} <{row['email']}> ({row['id']})" for row in accounts.values())
or "No fixture email accounts found.",
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
"output": "Fixture mailbox (default)",
"exit_code": 0,
}
@@ -139,7 +138,14 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
rows = [
_row(i, m) for i, m in enumerate(messages)
if _matches(m, query) and _folder_matches(m.get("folder"), folder)
and _account_matches(m, account)
and (
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))
try:
@@ -150,7 +156,11 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
return {"output": "No emails found.", "emails": [], "exit_code": 0}
output_lines = [f"Found {len(rows)} email(s):", ""]
for index, r in enumerate(rows, start=1):
summary = r["summary"].strip()
source = next(
(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([
f"{index}. **{r['subject']}**",
f" From: {r['from']}",
@@ -168,17 +178,14 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
uid = str(args.get("uid") or "")
message_id = str(args.get("message_id") or "")
folder = str(args.get("folder") or "INBOX")
if not uid and not message_id:
return {"error": "read_email requires a UID or Message-ID.", "exit_code": 1}
matches = [(m, _row(i, m)) for i, m in enumerate(messages)
if _folder_matches(m.get("folder"), folder) and _account_matches(m, args.get("account"))
and (not uid or str(m.get("uid") or _uid(i)) == uid)
and (not message_id or str(m.get("message_id") or "") == message_id)]
if not matches:
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]
try:
index = int(uid) - 1
except (TypeError, ValueError):
index = -1
if index < 0 or index >= len(messages) or not _folder_matches(messages[index].get("folder"), folder):
return {"error": f"Email UID {uid} not found.", "exit_code": 1}
message = messages[index]
row = _row(index, message)
output = (
f"UID: {row['uid']}\nSubject: {row['subject']}\nFrom: {row['from']}\n"
f"Date: {row['date']}\n\n{message.get('body') or ''}"
+7
View File
@@ -47,6 +47,13 @@ DEFAULT_SETTINGS = {
"image_gen_enabled": False,
"image_model": "",
"image_quality": "medium",
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
# back to the hosted chat model. See src/editor_agent_model.py.
"editor_agent_base_url": "",
"editor_agent_model": "",
"editor_caption_base_url": "",
"editor_caption_model": "",
"vision_model": "",
"vision_enabled": True,
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
+33 -3
View File
@@ -355,6 +355,36 @@ def _normalize_chat_endpoint(url: str) -> str:
return url
def _resolve_research_settings(get_setting_fn):
"""Resolve the scheduled-research settings with crash-proof fallbacks.
A hand-edited or agent-written data/settings.json can hold non-numeric
values for the research_* keys; the bare int() reads previously raised
inside TaskScheduler._execute_research_task and killed every scheduled
research run until settings.json was fixed by hand (#5049).
src/research_handler.py already guards the same reads defensively —
this mirrors that for the scheduler. Valid values pass through
unchanged (numeric strings like "4096" still parse).
"""
try:
max_tokens = int(get_setting_fn("research_max_tokens", 8192))
except (TypeError, ValueError, OverflowError):
max_tokens = 8192
try:
extraction_timeout = int(
get_setting_fn("research_extraction_timeout_seconds", 90) or 90
)
except (TypeError, ValueError, OverflowError):
extraction_timeout = 90
try:
extraction_concurrency = int(
get_setting_fn("research_extraction_concurrency", 3) or 3
)
except (TypeError, ValueError, OverflowError):
extraction_concurrency = 3
return max_tokens, extraction_timeout, extraction_concurrency
class TaskScheduler:
def __init__(self, session_manager):
self._session_manager = session_manager
@@ -2187,9 +2217,9 @@ class TaskScheduler:
pass
endpoint_url = _normalize_chat_endpoint(endpoint_url)
max_tokens = int(get_setting("research_max_tokens", 8192))
extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90)
extraction_concurrency = int(get_setting("research_extraction_concurrency", 3) or 3)
max_tokens, extraction_timeout, extraction_concurrency = (
_resolve_research_settings(get_setting)
)
researcher = DeepResearcher(
llm_endpoint=endpoint_url,
+10 -4
View File
@@ -425,9 +425,13 @@ class ToolApprovalStore:
# approval exists to admit an ungranted one. Seal its launch
# boundary now, in the request's workspace, the same way an
# ungranted filesystem action gets its root below (#6635).
# With no workspace bound, bash/python run in the agent
# workspace (agent_cwd), so the launch is sealed there.
roots = request_authority.resource_roots
if not roots and workspace:
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
if not roots:
from src.tool_execution import default_agent_workspace
roots = (FilesystemRoot.seal(workspace or default_agent_workspace(),
owner=_normalized_owner(owner)),)
if len(roots) == 1:
from dataclasses import replace
process_authority = replace(
@@ -455,8 +459,10 @@ class ToolApprovalStore:
if tool_name in NATIVE_FILESYSTEM_TOOLS and backend_operation is not None and isinstance(backend_operation.resource, NativeBackendResource):
try:
roots = request_authority.resource_roots if request_authority is not None else ()
if not roots and workspace and (request_authority is None or not request_authority.inherited):
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
if not roots and (request_authority is None or not request_authority.inherited):
from src.tool_execution import default_agent_workspace
roots = (FilesystemRoot.seal(workspace or default_agent_workspace(),
owner=_normalized_owner(owner)),)
resource_operation = resolve_filesystem_operation(
ExactOperation.normalize(tool_name, content), roots=roots, workspace=workspace or "",
request_id=request_authority.request_id if request_authority is not None else "")
+14
View File
@@ -216,6 +216,20 @@ _register(
ToolEffect.WRITE_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"ai_edit_image"},
ToolEffect.NETWORK_EGRESS,
ToolEffect.WRITE_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"get_editor_screenshot"},
ToolEffect.READ_PRIVATE,
)
_register(
{"plan_edit", "run_editor_command"},
ToolEffect.UI_SIDE_EFFECT,
)
_register(
{
"archive_email",
+29 -3
View File
@@ -1068,6 +1068,10 @@ def _resolve_tool_path(raw_path: str) -> str:
if raw_path is None or not str(raw_path).strip():
raise ValueError("path is required")
expanded = os.path.expanduser(str(raw_path).strip())
# Relative paths resolve where bash/python run (agent_cwd), not against
# the server's cwd, which is outside the allowed roots (#6679).
if not os.path.isabs(expanded):
expanded = os.path.join(_AGENT_WORKDIR, expanded)
resolved = os.path.realpath(expanded)
if _is_app_state_path(resolved):
@@ -1221,6 +1225,12 @@ def agent_cwd() -> str:
if vetted is None:
raise RuntimeError("active workspace is not a safe real directory")
return vetted
return default_agent_workspace()
def default_agent_workspace() -> str:
"""The agent workspace under the data dir, where subprocesses run when no
workspace is bound."""
resolved = os.path.realpath(_AGENT_WORKDIR)
if resolved not in _agent_readable_data_subdirs() or vet_workspace(resolved) != resolved:
raise RuntimeError("agent workspace is not a safe real directory")
@@ -1278,6 +1288,16 @@ _ADMIN_TOOLS = {
"serve_preset",
"stop_served_model",
"cancel_download",
# Read-only Cookbook tools reach require_admin routes over the internal
# loopback without an owner check (see NON_ADMIN_BLOCKED_TOOLS in
# src/tool_security.py), so they are admin-only at dispatch as well.
"list_served_models",
"tail_serve_output",
"list_downloads",
"search_hf_models",
"list_cached_models",
"list_serve_presets",
"list_cookbook_servers",
}
@@ -1538,6 +1558,10 @@ def _agent_subprocess_env() -> dict:
return base
async def _direct_fallback(
tool: str,
content: str,
@@ -2173,7 +2197,8 @@ async def _execute_tool_block_impl(
if tool == "generate_image":
from src.ai_interaction import do_generate_image
desc = "generate_image"
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner))
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner, **({'incognito': True} if image_privacy else {})))
elif (tool in NATIVE_FILESYSTEM_TOOLS
and (active_resource_operation() is not None or tool != "apply_patch"
or not _tui_host_bridge_patch_url(client_runtime_context))):
@@ -2333,7 +2358,8 @@ async def _execute_tool_block_impl(
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
elif tool == "edit_image":
desc = "edit_image"
result = await dispatched(do_edit_image(content, owner=owner))
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
result = await dispatched(do_edit_image(content, owner=owner, **({'incognito': True} if image_privacy else {})))
elif tool == "edit_file":
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
desc = result.get("output") or result.get("error") or "edit_file"
@@ -2577,4 +2603,4 @@ def format_tool_result(description: str, result: Dict) -> str:
# External execution bridges are allowed to return canonical result shapes
# without using the built-in tools' output cap. Bound the final model-facing
# text here so one verbose command cannot consume the next request window.
return _truncate("\n".join(parts))
return _truncate("\n".join(parts))
+4
View File
@@ -191,6 +191,10 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
"app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
"edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.",
"plan_edit": "Plan a multi-step edit for the open image editor and return ordered editor ops to run.",
"run_editor_command": "Run one deterministic editor op (adjustments, masked filters, selection, layers, import/place, export, screenshot) on the open image editor.",
"get_editor_screenshot": "Capture the open image editor's current canvas as a PNG data URL for inspection.",
"ai_edit_image": "Apply an AI image edit (remove/inpaint objects, generative replace, remove-bg, upscale, denoise, enhance-face) to the open image and place the result as a new layer. Use for object removal/addition/replacement; never approximate those with selection/adjustment/filter ops.",
"trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.",
"manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.",
}
+83 -3
View File
@@ -8,6 +8,7 @@ Extracted from agent_tools.py to keep schema definitions separate from
tool parsing / execution logic.
"""
import os
import json
import logging
import re
@@ -113,6 +114,15 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
args["url"] = path
args.pop("path", None)
args.pop("file_path", None)
elif (
tool_type == "ui_control"
and str(args.get("action") or "").casefold() == "open_panel"
and not str(args.get("name") or "").strip()
):
panel = _first_nonempty_string(args, ("panel",))
if panel:
args["name"] = panel
args.pop("panel", None)
if tool_type == "manage_documents" and "limit" not in args and "max_results" in args:
# Collection APIs use both names across the native tool surface. The
# document contract calls this integer ``limit``.
@@ -904,13 +914,13 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "ui_control",
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, calendar/schedule, email, sessions, notes, memories/brain, skills, settings, theme, cookbook; calendar supports month/week/year/agenda plus a date; Cookbook supports models/download, launch/serve, active/running, dependencies, and settings views), open_email_reply (legacy UI-only reply opener; prefer email MCP draft_email_reply for assistant-written reply drafts so a normal document-backed email draft is created), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation), get_theme, and get_toggles. When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, calendar/schedule, email, sessions, notes, tasks, memories/brain, skills, settings, theme, cookbook; calendar supports month/week/year/agenda plus a date; Cookbook supports models/download, launch/serve, active/running, dependencies, and settings views), open_email_reply (legacy UI-only reply opener; prefer email MCP draft_email_reply for assistant-written reply drafts so a normal document-backed email draft is created), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation), get_theme, and get_toggles. When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["toggle", "open_panel", "open_email_reply", "set_mode", "switch_model", "set_theme", "create_theme", "get_theme", "get_toggles"],
"description": "The UI action. Use set_theme for presets, create_theme to build a custom theme with any hex colors"},
"name": {"type": "string", "description": "For toggle: web, bash, research, incognito, document_editor (aliases: shell, search, deepresearch, documents). For open_panel: documents, gallery, calendar/schedule, email, sessions, notes, brain/memories, skills, settings, theme/themes, cookbook; models and serve are Cookbook-view aliases. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
"name": {"type": "string", "description": "For toggle: web, bash, research, incognito, document_editor (aliases: shell, search, deepresearch, documents). For open_panel: documents, gallery, calendar/schedule, email, sessions, notes, tasks, brain/memories, skills, settings, theme/themes, cookbook; models and serve are Cookbook-view aliases. For open_email_reply: email UID. For set_theme: a preset theme name. For create_theme: the custom theme name."},
"view": {"type": "string", "description": "Optional open_panel subview: calendar day/week/month/year/agenda, or Cookbook models/download, launch/serve, active/running, dependencies, settings."},
"value": {"type": "string", "description": "Value: on/off for toggle, agent/chat for set_mode, model name for switch_model, theme name for set_theme, or folder for open_email_reply"},
"uid": {"type": "string", "description": "Email UID for open_email_reply"},
@@ -1510,6 +1520,75 @@ FUNCTION_TOOL_SCHEMAS = [
}
}
},
{
"type": "function",
"function": {
"name": "plan_edit",
"description": "Plan a photo-editing program for the currently open image editor. Returns an ordered list of editor ops to run against the canvas. Route object removal, added content, or replaced content to ai_edit_image/generate_image; use deterministic ops only for tone/colour/filter/crop/text/selection edits. If no image provider is configured for a generative request, say so instead of planning a substitute deterministic edit. Use the canonical adjustment/filter shapes (adjustments.apply/applyMasked params {\"type\", \"params\"}; filters.apply/applyMasked params {\"id\"}); never invent adjustment types or filter ids. New ops: photo.autoTone; retouch.skin/blemish/teeth; selection.face/background. Portrait/look-better ('fix the skin', 'fix my teeth') -> selection.face then retouch.skin (and retouch.teeth for teeth) then ONE gentle photo.autoTone; never a timid single step. Low-key/'fix the lighting' on a dark image: at most ONE global lift (prefer photo.autoTone once or shadows-highlights); never stack autoTone/brightness/exposure/levels, and keep highlights <= 0. Temperature sign: moody/cool/cinematic -> white-balance temperature NEGATIVE; warm/golden/sunset -> POSITIVE. Background softer -> selection.background then filters.applyMasked {id:'blur-gaussian'} (never a plain global blur). A vague prompt with no concrete defect ('clean this up') -> return {\"reply\": <question>, \"steps\": [], \"ask_user\": {\"question\":..., \"options\":[...]}} with 2-3 concrete options instead of guessing.",
"parameters": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "What the user wants done to the image"},
"state": {"type": "object", "description": "Optional current editor state (layers, selection, document info)"}
},
"required": ["prompt"]
}
}
},
{
"type": "function",
"function": {
"name": "run_editor_command",
"description": "Run a single deterministic operation on the open image editor and return its result. Use catalog ops such as adjustments.*, filters.applyMasked, selection.*, layers.*, document.importImage, layers.placeImage, export.png, screenshot. Do NOT use for AI/masked edits (use ai_edit_image) or new imagery (use generate_image). Canonical shapes: adjustments.apply/applyMasked params = {\"type\": <brightness-contrast|exposure|white-balance|hue-saturation|vibrance|black-white|shadows-highlights|levels|curves|color-balance|photo-filter|selective-color|gradient-map|invert|posterize|threshold|channel-mixer|color-lookup>, \"params\": {...}}; filters.apply/applyMasked params = {\"id\": <blur-gaussian|blur-motion|blur-zoom|sharpen|sharpen-edges|unsharp-mask|noise-add|noise-despeckle|pixelate-mosaic|pixelate-crystallize|stylize-emboss|stylize-find-edges|stylize-solarize|distort-twirl|distort-pinch|distort-spherize|distort-ripple|render-clouds>}. Never nest a numeric bag under {\"adjustments\": {...}} and never invent adjustment types or filter ids (no \"vignette\" or \"clarity\"). Additional canonical ops: photo.autoTone {}; retouch.skin {smooth,opacity,blemish,radius,threshold}; retouch.blemish {radius,threshold}; retouch.teeth {desat,bright}; selection.face {mode}; selection.background {mode,subject,fuzziness}. At most one global brightness/exposure lift (prefer shadows-highlights; keep highlights <= 0). Never stack global lifts.",
"parameters": {
"type": "object",
"properties": {
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
"params": {"type": "object", "description": "Parameters for the op"},
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
},
"required": ["op"]
}
}
},
{
"type": "function",
"function": {
"name": "get_editor_screenshot",
"description": "Capture the open image editor's current canvas as a PNG data URL. Use to inspect the result before choosing the next edit.",
"parameters": {
"type": "object",
"properties": {
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
}
}
}
},
{
"type": "function",
"function": {
"name": "ai_edit_image",
"description": "Apply an AI image edit and place the result as a NEW layer, never overwriting the original. Two modes. mode='instruction' (default): send the WHOLE image with a clear prompt and no mask — use a rephrased instruction that NAMES what must stay unchanged, e.g. 'replace the background with a sunny beach, keep the woman exactly the same'. Use this for background/scene replacement, changing the season or style, adding an object to the scene (a moon, a person), and removing diffuse distractions. mode='masked': masked inpaint on a selected region — use ONLY for an explicit regional removal of a clearly identified object ('remove the person on the left'); select the object first so the mask is tight. NEVER approximate object removal/addition/replacement with selection, adjustment, filter or transform ops; if no image provider is configured, report that instead of substituting a deterministic edit. Other ops: remove-bg, upscale (2x/4x), denoise, enhance-face.",
"parameters": {
"type": "object",
"properties": {
"op": {"type": "string", "enum": ["remove-bg", "upscale", "denoise", "enhance-face", "inpaint", "generative"], "description": "AI edit operation"},
"mode": {"type": "string", "enum": ["instruction", "masked"], "description": "instruction = whole-image edit with no mask (background/season/style/add-object/diffuse removal); masked = tight-mask regional removal of a selected object. Default instruction."},
"prompt": {"type": "string", "description": "Instruction for inpaint/generative (in instruction mode, name what must stay unchanged)"},
"image": {"type": "string", "description": "Optional source (data URL or gallery image id); defaults to the current canvas/screenshot"},
"mask": {"type": "string", "description": "Optional base64 PNG mask (white = region to edit); required for mode='masked'"},
"scale": {"type": "number", "description": "Upscale factor (2 or 4)"},
"strength": {"type": "number", "description": "Denoise strength (0-1)"},
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
"session": {"type": "string", "description": "Editor session id"}
},
"required": ["op"]
}
}
},
{
"type": "function",
"function": {
@@ -1922,6 +2001,7 @@ FUNCTION_TOOL_SCHEMAS = [
]
# ---------------------------------------------------------------------------
# Converter: native function call -> ToolBlock
# ---------------------------------------------------------------------------
@@ -2380,4 +2460,4 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
else:
content = json.dumps(args)
return ToolBlock(tool_type, content)
return ToolBlock(tool_type, content)
+18
View File
@@ -79,6 +79,22 @@ NON_ADMIN_BLOCKED_TOOLS = BUILTIN_EMAIL_TOOLS | {
"stop_served_model",
"cancel_download",
"adopt_served_model",
# Read-only Cookbook inspection tools. They look harmless, but every one
# of them calls a require_admin route over the internal loopback with the
# process-wide INTERNAL_TOOL_TOKEN and never checks the session owner, so
# a non-admin agent would inherit admin reach through them:
# list_cached_models forwards a caller-chosen `host` into
# /api/model/cached, which SSHes to that host with the app user's keys and
# walks caller-chosen `model_dir`s; list_served_models / tail_serve_output
# expose serve/download logs and running process command lines. Model
# serving is admin-only in THREAT_MODEL.md, so block the read side too.
"list_served_models",
"tail_serve_output",
"list_downloads",
"search_hf_models",
"list_cached_models",
"list_serve_presets",
"list_cookbook_servers",
}
@@ -127,6 +143,7 @@ PLAN_MODE_READONLY_TOOLS = {
"resolve_contact",
"chat_with_model",
"ask_teacher",
"get_editor_screenshot",
}
@@ -163,6 +180,7 @@ _PLAN_MODE_KNOWN_MUTATORS = {
"download_model", "serve_model",
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
"generate_image", "edit_image", "trigger_research", "manage_research",
"plan_edit", "run_editor_command", "ai_edit_image",
# Shell is never read-only-safe; block it explicitly so it stays out of plan
# mode even if the schema list fails to load.
"bash", "python",
+1
View File
@@ -28,5 +28,6 @@ TOOL_TAGS = {
"tail_serve_output", "list_downloads", "cancel_download", "search_hf_models",
"list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model",
"list_cookbook_servers", "edit_image", "trigger_research", "manage_research",
"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image",
"app_api",
} | BUILTIN_EMAIL_TOOLS
+37
View File
@@ -325,6 +325,26 @@ def _task_structured_schedule(args, fallback_time=None):
'cron_expression': f'{parsed.minute} {parsed.hour} * * {cron_days}'}
def _admin_task_action_denied(owner: Optional[str], task_type: Optional[str], action_name: Optional[str]) -> Optional[Dict]:
"""Apply the shared admin-only task-action policy to a tool call.
``POST /api/tasks`` and the scheduler both refuse ``run_local``,
``run_script``, ``ssh_command`` and ``cookbook_serve`` action tasks for
owners without admin task privileges (``src.task_action_policy``).
Those actions reach ``subprocess.run(shell=True)`` / SSH with no sandbox,
so the model-facing tool must apply the same policy at create/edit/
resume/run time instead of storing a task the scheduler will only pause
later. Returns the tool error dict to hand back, or None when allowed.
"""
from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
)
if is_admin_only_task_action(task_type, action_name) and not owner_has_admin_task_privileges(owner):
return {"error": f"Action '{action_name}' requires admin privileges", "exit_code": 1}
return None
async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
"""Handle manage_tasks tool calls: CRUD on scheduled tasks."""
import uuid as _uuid
@@ -462,6 +482,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
return {"error": "Prompt is required for llm/research tasks", "exit_code": 1}
if task_type == "action" and not args.get("action_name"):
return {"error": "action_name is required for action tasks", "exit_code": 1}
denied = _admin_task_action_denied(owner, task_type, args.get("action_name"))
if denied:
return denied
# Compute next_run for schedule triggers
next_run = None
@@ -525,6 +548,14 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
if owner and task.owner != owner:
return {"error": "Access denied", "exit_code": 1}
# Check the policy against the task as it would be after the
# edit, so an llm task can't be flipped into a shell action.
next_task_type = args["task_type"] if args.get("task_type") is not None else task.task_type
next_action = args["action_name"] if args.get("action_name") is not None else task.action
denied = _admin_task_action_denied(owner, next_task_type, next_action)
if denied:
return denied
if 'weekdays' in args or 'day_of_month' in args:
clock = task.scheduled_time
if task.schedule == 'cron':
@@ -624,6 +655,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
if action == "pause":
task.status = "paused"
else:
denied = _admin_task_action_denied(owner, task.task_type, task.action)
if denied:
return denied
task.status = "active"
if (task.trigger_type or "schedule") == "schedule":
task.next_run = compute_next_run(
@@ -644,6 +678,9 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
return error
if owner and task.owner != owner:
return {"error": "Access denied", "exit_code": 1}
denied = _admin_task_action_denied(owner, task.task_type, task.action)
if denied:
return denied
from src.event_bus import get_task_scheduler
scheduler = get_task_scheduler()
+26 -3
View File
@@ -37,6 +37,7 @@ FAMILY_TOOLS = {
"sessions": frozenset({"list_sessions", "manage_session", "create_session", "send_to_session", "chat_with_model", "pipeline"}),
"image_generation": frozenset({"generate_image"}),
"image_editing": frozenset({"edit_image"}),
"photo_editor": frozenset({"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image", "generate_image"}),
"transcription": frozenset({"transcribe_media"}),
"media_inspection": frozenset({"inspect_media"}),
"ocr": frozenset({"extract_text"}),
@@ -487,7 +488,8 @@ _BOUND_EDITOR_WRITE = re.compile(
re.I,
)
_BOUND_EDITOR_IMPLICIT_REVISION = re.compile(
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|"
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|go\s+more\s+into|"
r"g[aå]\s+mer\s+(?:in|ut)\s+p[aå]|"
r"clean\s+(?:this|it|the\s+(?:text|draft|document|doc))\s+up|"
r"give\s+(?:me\s+)?(?:feedback|a\s+critique|suggestions?|comments?)|"
r"remove\b[^.;\n]{0,100}\b(?:mistakes?|errors?|inaccurac(?:y|ies)|misinformation)|"
@@ -633,6 +635,7 @@ _REQUIRED_TOOLS = {
"calendar": "manage_calendar", "notes": "manage_notes",
"tasks": "manage_tasks", "skills": "manage_skills",
"image_generation": "generate_image", "image_editing": "edit_image",
"photo_editor": "plan_edit",
"transcription": "transcribe_media", "media_inspection": "inspect_media", "ocr": "extract_text",
}
@@ -2994,6 +2997,18 @@ def _natural_safe_inventory_operation(message: str, maximum: int | None = None)
return RequiredReadOperation(tool, {"action": action} if action else {}, maximum)
def note_read_intent(user_text: str) -> tuple[str, bool]:
"""Return a quoted mention query and whether the note body is needed."""
text = str(user_text or "")
mention = re.search(r"\bmentions?\s+(['\"])([^'\"]{1,120})\1", text, re.I)
query = mention.group(2).strip() if mention else ""
needs_body = bool(
re.search(r"\b(?:summari[sz]e|summary|main\s+topics)\b", text, re.I)
or re.search(r"\b(?:show|open|read|view)\b.{0,80}\b(?:first|newest|most\s+recent)\s+note\b", text, re.I)
)
return query, needs_body
def required_read_operation_for_request(message: str, history: Iterable = ()) -> RequiredReadOperation | None:
"""Resolve complete list/read requests, or repeat an exact prior read.
@@ -4149,7 +4164,15 @@ def _families_for_tool(tool: str) -> frozenset[str]:
if bare in {"list_sessions", "manage_session", "create_session", "send_to_session",
"chat_with_model", "pipeline"}:
return frozenset({"sessions"})
return frozenset(family for family, tools in FAMILY_TOOLS.items() if bare in tools)
# The photo_editor family is context-only: it is only ever selected by an
# explicit editor request at the route, never inferred from message text.
# Overlapping tools such as generate_image must still resolve to their real
# product family, so exclude photo_editor rather than short-circuiting.
return frozenset(
family
for family, tools in FAMILY_TOOLS.items()
if bare in tools and family != "photo_editor"
)
def _clause_capabilities(text: str) -> set[str]:
@@ -6325,7 +6348,7 @@ def resolve_full_inventory_contract(*, schemas: Iterable[dict], policy: ToolPoli
"""Experimental trained inventory: permissions filter offers; model chooses actions."""
families = frozenset({"calendar", "notes", "tasks", "skills", "memory", "documents",
"email", "search_browser", "shell_files", "cookbook_admin",
"image_editing", "image_generation"})
"image_editing", "image_generation", "photo_editor"})
# ``ui_control`` is the executable bridge for explicit client-interface
# requests (for example, opening the gallery). It is not one of the ten
# persisted-data families, but omitting it here makes the full-inventory
+70 -9
View File
@@ -63,19 +63,80 @@ INTERNAL_UPLOAD_URL_RE = re.compile(
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
r"(?=$|[\s\"'<>\[\](){},;!?:&#]|\.(?![A-Za-z0-9]))"
)
PDF_SOURCE_UPLOAD_RE = re.compile(
r"<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id="
r"[\"']([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)[\"'][^>]*-->",
# `<!--\s*pdf(?:_form)?_source\b[^>]*\bupload_id=["'](id)["'][^>]*-->` and
# `\[Attachment:[^\]\r\n]*\|\s*id=(id)(?:\s*\||\s*\])` are matched by the
# forward-only scanners below. As single regexes, every opener in a run with no
# 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,
)
ATTACHMENT_REFERENCE_LINE_RE = re.compile(
r"\[Attachment:[^\]\r\n]*\|\s*id="
r"([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)"
r"(?:\s*\||\s*\])",
_ATTACHMENT_REFERENCE_OPEN_RE = re.compile(r"\[Attachment:", re.IGNORECASE)
_ATTACHMENT_REFERENCE_STOP_RE = re.compile(r"[\]\r\n]")
_ATTACHMENT_REFERENCE_TAIL_RE = re.compile(
r"\|\s*id=([0-9a-fA-F]{32}(?:\.[A-Za-z0-9]+)?)(?:\s*\||\s*\])",
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:
"""Return True when *upload_id* matches the canonical uploads.json id format."""
return UPLOAD_ID_RE.fullmatch(upload_id or "") is not None
@@ -110,8 +171,8 @@ def extract_internal_upload_ids(value: Any) -> set[str]:
return set()
return (
set(INTERNAL_UPLOAD_URL_RE.findall(value))
| set(PDF_SOURCE_UPLOAD_RE.findall(value))
| set(ATTACHMENT_REFERENCE_LINE_RE.findall(value))
| set(_pdf_source_upload_ids(value))
| set(_attachment_reference_ids(value))
)
+66
View File
@@ -57,6 +57,10 @@ _SHARED_ADDRESS_SPACE_V4 = ipaddress.ip_network("100.64.0.0/10")
# membership test excludes and which stays rejected as reserved.
_NAT64_WELL_KNOWN_PREFIX_V6 = ipaddress.ip_network("64:ff9b::/96")
_LOCALHOST_NAMES = frozenset({"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"})
# Alibaba's metadata endpoint is shared address space, not link-local.
_METADATA_IPS = {ipaddress.ip_address("100.100.100.200")}
def _nat64_well_known_embedded_ipv4(
ip: ipaddress._BaseAddress,
@@ -92,6 +96,8 @@ def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[st
if reason:
return f"NAT64 translated destination blocked: {reason}"
return None
if ip in _METADATA_IPS:
return f"metadata endpoint blocked: {ip}"
if ip.is_link_local:
return f"link-local address blocked (SSRF metadata risk): {ip}"
# IPv6 loopback is also reserved; its policy must match IPv4 loopback.
@@ -161,6 +167,58 @@ def check_outbound_url(
def check_outbound_host(
host: str,
*,
block_private: bool = False,
resolver: Optional[Callable[[str], List[str]]] = None,
) -> Tuple[bool, str]:
"""Validate a bare mail hostname/IP, rejecting unresolved destinations.
Saving an unresolvable hostname is unsafe: another consumer of the stored
configuration may later connect after its DNS records change. Live mail
connections separately pin their sockets with connect_outbound_tcp.
"""
if not isinstance(host, str):
return False, "host must be a string"
host = host.strip()
if not host:
return False, "host is required"
if any(ch.isspace() for ch in host) or any(ch in host for ch in "/\\@?#"):
return False, "host must be a bare hostname or IP address"
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
try:
ip = ipaddress.ip_address(literal.split("%")[0])
except ValueError:
if ":" in literal:
return False, "host must be a bare hostname or IP address"
else:
reason = _classify(ip, block_private=block_private)
return (False, reason) if reason else (True, "ok")
name = host.lower().rstrip(".")
if name in _LOCALHOST_NAMES or name.endswith(".localhost"):
if block_private:
return False, f"private/shared/loopback address blocked: {host}"
return True, "ok"
try:
ips = (resolver or _default_resolver)(host)
if not ips:
return False, "host does not resolve to an IP"
saw_ip = False
for raw in ips:
# Malformed answers fail closed, including mixed valid/invalid ones.
ip = ipaddress.ip_address(raw.split("%")[0])
saw_ip = True
reason = _classify(ip, block_private=block_private)
if reason:
return False, reason
except Exception as exc:
return False, f"host does not resolve to valid IP addresses: {exc}"
if not saw_ip:
return False, "host does not resolve to an IP"
return True, "ok"
class OutboundAddressBlocked(PermissionError):
"""A non-HTTP outbound host resolves into a disallowed address range."""
@@ -183,7 +241,15 @@ def connect_outbound_tcp(
a rebinding name pass the check with a public answer and then connect to
metadata/private space. TLS callers keep wrapping the returned socket with
``server_hostname=host``, so SNI and certificate checks are unchanged.
An injected resolver must implement socket.getaddrinfo's argument and
result contract. Resolver errors propagate without an unvetted retry.
"""
if isinstance(port, bool) or not isinstance(port, (int, str)):
raise ValueError("port must be an integer between 1 and 65535")
port = int(port)
if not 1 <= port <= 65535:
raise ValueError("port must be an integer between 1 and 65535")
resolve = resolver or socket.getaddrinfo
infos = resolve(host, port, 0, socket.SOCK_STREAM)
for _family, _type, _proto, _canon, sockaddr in infos: