From 8f786f997eaf74216a54aa8cdbaf66cccc3ad37d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:09:20 -0700 Subject: [PATCH 1/7] =?UTF-8?q?refactor(tools):=20image=20gen=20=E2=80=94?= =?UTF-8?q?=20compact=20docstrings/messages,=20check=5Ffal=5Fapi=5Fkey=20v?= =?UTF-8?q?ia=20gateway=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/fal_common.py | 10 +- tools/image_generation_tool.py | 188 ++++++++++++--------------------- tools/image_source.py | 34 +++--- 3 files changed, 84 insertions(+), 148 deletions(-) diff --git a/tools/fal_common.py b/tools/fal_common.py index 45055feff9..daa1d37c01 100644 --- a/tools/fal_common.py +++ b/tools/fal_common.py @@ -15,8 +15,7 @@ from urllib.parse import urlencode def import_fal_client() -> Any: """Import ``fal_client`` (via ``lazy_deps`` when available); raises ImportError if unavailable. - Not imported at cold start (~64 ms per CLI invocation). Callers cache the result on their - own module global so tests can monkeypatch that module's ``fal_client`` attribute. + Callers cache the result on their own module global so tests can monkeypatch it. """ try: from tools.lazy_deps import ensure as _lazy_ensure @@ -54,11 +53,8 @@ def _require(value: Any, what: str) -> Any: class _ManagedFalSyncClient: - """Per-instance wrapper driving a Nous-managed fal-queue gateway via ``fal_client.SyncClient`` primitives. - - Carries its own ``fal_client`` reference instead of a module global so the caller decides - which module's (possibly test-patched) ``fal_client`` is used. - """ + """Drives a Nous-managed fal-queue gateway via ``fal_client.SyncClient`` primitives; carries + its own ``fal_client`` reference so the caller decides which (possibly test-patched) module is used.""" def __init__(self, fal_client: Any, *, key: str, queue_run_origin: str): sync_client_class = _require(getattr(fal_client, "SyncClient", None), "fal_client.SyncClient") diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 70f9ac7fb6..913ab61601 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -1,9 +1,8 @@ #!/usr/bin/env python3 """Image generation via FAL.ai (model picked in ``hermes tools``, persisted to ``image_gen.model``). -``FAL_MODELS`` (``tools.image_generation_catalog``) holds per-model metadata; -``_build_fal_payload()`` / ``_build_fal_edit_payload()`` translate unified inputs into -the model payload filtered to its ``supports`` whitelist so models never receive rejected +``_build_fal_payload()`` / ``_build_fal_edit_payload()`` translate unified inputs into the +``FAL_MODELS`` payload filtered to its ``supports`` whitelist so models never receive rejected keys. Clarity upscaling is strictly per-call opt-in: default-on degraded text/CJK/faces. """ @@ -15,8 +14,8 @@ import threading import uuid from typing import Any, Dict, Optional -# Imported lazily by _load_fal_client(): the eager import cost ~64 ms on every CLI cold -# start. Tests that monkeypatch this attribute keep working (loader short-circuits when truthy). +# Imported lazily by _load_fal_client() (~64 ms on every CLI cold start); a test-monkeypatched +# value short-circuits the loader. fal_client: Any = None @@ -58,10 +57,9 @@ _managed_fal_client_lock = threading.Lock() def _resolve_managed_fal_gateway(): """Managed gateway config for the stored `hermes tools` selection, or ``None`` for direct FAL. - ``"nous"`` (or legacy ``use_gateway: true``) → managed ONLY: not entitled/unreachable is a - selection-naming error, never a silent FAL_KEY fallback. Any other stored provider → direct - ONLY: missing FAL_KEY is an error naming FAL_KEY and the selection, never a silent managed - reroute. Never configured → legacy autodetect: direct if FAL_KEY, else managed if resolvable. + ``"nous"`` (or legacy ``use_gateway: true``) → managed ONLY (unreachable = selection-naming + error, never a silent FAL_KEY fallback). Other stored provider → direct ONLY (missing FAL_KEY + = error naming the selection). Never configured → autodetect: direct if FAL_KEY, else managed. """ selected = read_selection("image_gen") if selected == NOUS_MANAGED_PROVIDER: @@ -73,9 +71,9 @@ def _resolve_managed_fal_gateway(): )) return gateway if selected is not None: - if not fal_key_is_configured(): - raise ValueError(selection_error("image_gen", selected, "FAL_KEY is not set")) - return None + if fal_key_is_configured(): + return None + raise ValueError(selection_error("image_gen", selected, "FAL_KEY is not set")) # Never-configured category: legacy credential autodetect (do NOT persist). return None if fal_key_is_configured() else resolve_managed_tool_gateway("fal-queue") @@ -103,15 +101,13 @@ class ImageGenerationInterrupted(Exception): def _wait_fal_result(handler, *, poll_seconds: float = 0.5): """Interrupt-aware ``handler.get()``: the SDK blocks 30-60s, hiding user interrupts. - Runs the get on a daemon worker and polls the per-thread interrupt bit between join - slices; on interrupt the worker is abandoned (remote job keeps running) and - ``ImageGenerationInterrupted`` is raised. + The get runs on a daemon worker; the interrupt bit is polled between join slices and on + interrupt the worker is abandoned (remote job keeps running). """ from tools.interrupt import is_interrupted result_box: list = [] error_box: list = [] - def _get(): try: result_box.append(handler.get()) @@ -153,13 +149,11 @@ def _submit_fal_request(model: str, arguments: Dict[str, Any]): "managed FAL image generation", force_fresh=True, ) raise ValueError( - f"Nous Subscription gateway rejected model '{model}' " - f"(HTTP {status}). This model may not yet be enabled on " - f"the Nous Portal's FAL proxy. Either:\n" + f"Nous Subscription gateway rejected model '{model}' (HTTP {status}). This model " + f"may not yet be enabled on the Nous Portal's FAL proxy. Either:\n" f" • Set FAL_KEY in your environment to use FAL.ai directly, or\n" f" • Pick a different model via `hermes tools` → Image Generation." - f"{gateway_message}" - ) from exc + f"{gateway_message}") from exc raise @@ -184,12 +178,8 @@ def _read_configured_image_model(): def _read_configured_image_provider(): - """``image_gen.provider`` from config.yaml, or None. - - The plugin registry is consulted only when this is explicitly set — unset keeps users on - the in-tree FAL fallback even when other providers are registered. ``"fal"`` routes through - ``plugins/image_gen/fal/``, which delegates back here via call-time indirection. - """ + """``image_gen.provider`` from config.yaml, or None (unset keeps the in-tree FAL fallback even + when other providers are registered; ``"fal"`` routes via ``plugins/image_gen/fal/``).""" return _read_image_gen_key("provider") @@ -219,9 +209,9 @@ def _build_payload(model_id, prompt, aspect_ratio, seed, overrides, image_urls=N """Text-to-image / edit payload (``image_urls`` selects edit mode): defaults + native size spec + overrides, filtered to the model whitelist. - Edit endpoints mostly auto-infer size from the input, so the size key is sent only when - ``edit_supports`` advertises it. ``prompt`` (and ``image_urls`` on edits) are required by - every FAL endpoint and survive a whitelist gap so a catalog mistake can't send a broken request. + Edit endpoints mostly auto-infer size, so the size key is sent only when ``edit_supports`` + lists it. ``prompt`` (and ``image_urls`` on edits) survive a whitelist gap: every FAL + endpoint requires them, so a catalog mistake can't send a broken request. """ meta = FAL_MODELS[model_id] edit = image_urls is not None @@ -265,12 +255,9 @@ def _upscale_image(image_url: str, original_prompt: str) -> Optional[Dict[str, A try: logger.info("Upscaling image with Clarity Upscaler...") handler = _submit_fal_request(UPSCALER_MODEL, arguments={ - "image_url": image_url, - "prompt": f"{UPSCALER_DEFAULT_PROMPT}, {original_prompt}", - "upscale_factor": UPSCALER_FACTOR, - "negative_prompt": UPSCALER_NEGATIVE_PROMPT, - "creativity": UPSCALER_CREATIVITY, - "resemblance": UPSCALER_RESEMBLANCE, + "image_url": image_url, "prompt": f"{UPSCALER_DEFAULT_PROMPT}, {original_prompt}", + "upscale_factor": UPSCALER_FACTOR, "negative_prompt": UPSCALER_NEGATIVE_PROMPT, + "creativity": UPSCALER_CREATIVITY, "resemblance": UPSCALER_RESEMBLANCE, "guidance_scale": UPSCALER_GUIDANCE_SCALE, "num_inference_steps": UPSCALER_NUM_INFERENCE_STEPS, "enable_safety_checker": UPSCALER_SAFETY_CHECKER, @@ -296,8 +283,8 @@ def _upscale_image(image_url: str, original_prompt: str) -> Optional[Dict[str, A # --- Artifact path hinting for non-local terminal backends --- _CONTAINER_HOME_ENVS = {"DockerEnvironment", "SingularityEnvironment", "ModalEnvironment"} -# No environment yet: only backends with deterministic cache roots can be translated without -# side effects. SSH uses a shell-visible tilde path; its first sync uploads the cache file. +# No env yet: only deterministic cache roots translate side-effect free (SSH: tilde path; its +# first sync uploads the cache file). _CACHE_BASE_BY_BACKEND = {"docker": "/root/.hermes", "singularity": "/root/.hermes", "modal": "/root/.hermes", "ssh": "~/.hermes"} @@ -403,16 +390,13 @@ def _prepare_fal_request(model_id, meta, prompt, aspect_ratio, seed, overrides, if not (fal_key_is_configured() or _resolve_managed_fal_gateway()): raise ValueError(_build_no_backend_setup_message()) - edit_endpoint = meta.get("edit_endpoint") - display = meta.get("display", model_id) + edit_endpoint, display = meta.get("edit_endpoint"), meta.get("display", model_id) # Fail clearly rather than silently dropping sources and producing an unrelated picture. if source_images and not edit_endpoint: raise ValueError( - f"Model '{display}' ({model_id}) is not " - f"capable of image-to-image / editing. Provide a text-only " - f"prompt (omit image_url), or switch to an edit-capable model " - f"via `hermes tools` → Image Generation." - ) + f"Model '{display}' ({model_id}) is not capable of image-to-image / editing. " + f"Provide a text-only prompt (omit image_url), or switch to an edit-capable model " + f"via `hermes tools` → Image Generation.") aspect_lc = (aspect_ratio or DEFAULT_ASPECT_RATIO).lower().strip() if aspect_lc not in VALID_ASPECT_RATIOS: @@ -442,9 +426,9 @@ def image_generate_tool( ) -> str: """Generate (or, with source images + an ``edit_endpoint`` model, edit) an image via FAL. - Extra kwargs are overrides for direct Python callers, filtered per-model via the - ``supports`` / ``edit_supports`` whitelist (dropped silently so legacy callers survive - model switches). Returns JSON ``{"success", "image", "modality", "error", "error_type"}``. + Extra kwargs are overrides filtered per-model via ``supports`` / ``edit_supports`` (dropped + silently so callers survive model switches). Returns JSON ``{"success", "image", "modality", + "error", "error_type"}``. """ model_id, meta = _resolve_fal_model() candidates = [image_url, *(reference_image_urls if isinstance(reference_image_urls, (list, tuple)) else [])] @@ -483,9 +467,8 @@ def image_generate_tool( if not images: raise ValueError("No images were generated") - # An explicit ``upscale`` wins over the catalog default, including for edits. The - # catalog default never upscales edits: Clarity is a text-to-image quality pass and - # must not silently alter edit compositions. + # Explicit ``upscale`` wins, including for edits; the catalog default never upscales + # edits (Clarity is a text-to-image pass and must not silently alter compositions). if upscale is not None: should_upscale = bool(upscale) else: @@ -520,12 +503,11 @@ def check_fal_api_key() -> bool: A stored-but-broken selection reports False here (registry gating); the naming error surfaces at call time from ``_resolve_managed_fal_gateway``. """ - selected = read_selection("image_gen") - if selected == NOUS_MANAGED_PROVIDER: - return bool(resolve_managed_tool_gateway("fal-queue")) - if selected is not None: - return fal_key_is_configured() - return bool(fal_key_is_configured() or resolve_managed_tool_gateway("fal-queue")) + try: + gateway = _resolve_managed_fal_gateway() + except ValueError: + return False + return bool(gateway) or fal_key_is_configured() def _build_no_backend_setup_message() -> str: @@ -539,22 +521,12 @@ def _build_no_backend_setup_message() -> str: gateway_message = nous_tool_gateway_unavailable_message("managed FAL image generation") if gateway_message: lines.append(f" - {gateway_message}") - lines += [ - "", - "To enable image generation, do one of:", - " 1. Get a free API key at https://fal.ai and set " - "FAL_KEY= (then restart the session)", - ] + lines += ["", "To enable image generation, do one of:", + " 1. Get a free API key at https://fal.ai and set FAL_KEY= (then restart the session)"] if managed: - lines.append( - " 2. Sign in to a Nous account that has the managed FAL " - "gateway enabled (`hermes setup`)" - ) - lines.append( - " 3. Configure a different image_gen provider via `hermes tools` " - "→ Image Generation (run `hermes plugins list` to see installed " - "backends)" - ) + lines.append(" 2. Sign in to a Nous account that has the managed FAL gateway enabled (`hermes setup`)") + lines.append(" 3. Configure a different image_gen provider via `hermes tools` → Image Generation " + "(run `hermes plugins list` to see installed backends)") return "\n".join(lines) @@ -661,12 +633,8 @@ def _dispatch_to_plugin_provider( prompt: str, aspect_ratio: str, image_url: Optional[str] = None, reference_image_urls: Optional[list] = None, upscale: Optional[bool] = None, ): - """JSON result from the selected plugin provider, or ``None`` to fall through to in-tree FAL. - - Fires when ``image_gen.provider`` is anything but unset / ``"fal"`` / ``"nous"`` (those run - the legacy pipeline; ``"nous"`` via the managed fal-queue gateway). Edit args are - forwarded for the backend's edit endpoint; providers without ``upscale`` ignore it via ``**kwargs``. - """ + """JSON result from the selected plugin provider, or ``None`` to fall through to in-tree FAL + (provider unset / ``"fal"`` / ``"nous"``). Providers without ``upscale`` ignore it via ``**kwargs``.""" configured = _plugin_provider_name() if configured is None: return None @@ -684,11 +652,8 @@ def _dispatch_to_plugin_provider( logger.debug("image_gen plugin force-refresh skipped: %s", exc) if provider is None: return _provider_error( - f"image_gen.provider='{configured}' is set but no plugin " - f"registered that name. Run `hermes plugins list` to see " - f"available image gen backends.", - "provider_not_registered", - ) + f"image_gen.provider='{configured}' is set but no plugin registered that name. " + f"Run `hermes plugins list` to see available image gen backends.", "provider_not_registered") pname = getattr(provider, "name", "?") kwargs: Dict[str, Any] = {"prompt": prompt, "aspect_ratio": aspect_ratio} @@ -702,13 +667,10 @@ def _dispatch_to_plugin_provider( logger.warning("image_gen provider '%s' rejected image-to-image kwargs " "(signature too narrow): %s", pname, exc) return _provider_error( - f"Provider '{pname}' does not " - f"support image-to-image / editing (its generate() " - f"signature is out of date with the image_generate schema). " - f"Omit image_url for text-to-image, or pick a backend that " - f"supports editing via `hermes tools` → Image Generation.", - "modality_unsupported", - ) + f"Provider '{pname}' does not support image-to-image / editing (its generate() " + f"signature is out of date with the image_generate schema). Omit image_url for " + f"text-to-image, or pick a backend that supports editing via `hermes tools` → " + f"Image Generation.", "modality_unsupported") logger.warning("Image gen provider '%s' raised TypeError: %s", pname, exc) return _provider_error(f"Provider '{pname}' error: {exc}", "provider_exception") except Exception as exc: @@ -734,9 +696,8 @@ def _maybe_route_managed_krea( ) -> Optional[str]: """JSON result from the managed Krea gateway, or ``None`` to fall through. - Fires only when the configured model is a native ``krea-2-*`` id AND no - ``image_gen.provider`` other than ``"nous"`` is stored (a picker choice dispatches - normally) AND the managed Krea gateway is resolvable. + Fires only for a native ``krea-2-*`` model with no ``image_gen.provider`` other than + ``"nous"`` stored (a picker choice dispatches normally) and a resolvable Krea gateway. """ configured_provider = _read_configured_image_provider() if configured_provider is not None and configured_provider != NOUS_MANAGED_PROVIDER: @@ -772,11 +733,9 @@ def _maybe_route_managed_krea( def _confine_source_images(image_url, reference_image_urls, task_id, *, permitted: tuple = ("image",)): """Resolve path-like sources to ``data:`` URLs under a non-local terminal backend. - Goes through ``tools.image_source`` (in-sandbox exec-read, media-cache host reads, - credential guard) before any provider sees them, so generation obeys the same - confinement boundary as vision/video analysis and sandbox-only files work as edit - sources. URLs/data: pass through; the local backend is a no-op (providers keep host reads). - Returns ``(image_url, reference_image_urls, error_json_or_None)``. + Routes through ``tools.image_source`` (in-sandbox exec-read, media-cache host reads, + credential guard) so generation obeys the same confinement as vision. URLs/data: pass + through; local backend is a no-op. Returns ``(image_url, reference_image_urls, error_json_or_None)``. """ if (os.getenv("TERMINAL_ENV") or "local").strip().lower() in ("", "local"): return image_url, reference_image_urls, None @@ -806,16 +765,14 @@ def _handle_image_generate(args, **kw): upscale = args.get("upscale") task_id = kw.get("task_id") - # Confinement chokepoint BEFORE any dispatch: plugin, managed Krea and in-tree FAL - # all receive sandbox-confined bytes. + # Confinement chokepoint BEFORE any dispatch: every route receives sandbox-confined bytes. image_url, reference_image_urls, confine_error = _confine_source_images( args.get("image_url"), args.get("reference_image_urls"), task_id) if confine_error is not None: return confine_error - # Order matters: explicit plugin provider (incl. provider == "krea"), then - # model-driven managed Krea interception (only when no provider is set, so - # the BYO/direct FAL path stays untouched), then the in-tree FAL pipeline. + # Order matters: explicit plugin provider (incl. "krea"), then model-driven managed Krea + # interception (only when no provider is set, so BYO/direct FAL stays untouched), then FAL. sources = dict(image_url=image_url, reference_image_urls=reference_image_urls, upscale=upscale if isinstance(upscale, bool) else None) raw = None @@ -827,20 +784,18 @@ def _handle_image_generate(args, **kw): # --- Dynamic schema — reflect the active backend's image-to-image capability --- -# Telling the model up front whether it can edit saves a wasted turn. Memoized by -# config.yaml mtime in model_tools.get_tool_definitions(), so it rebuilds on switch. +# Advertising edit capability up front saves a wasted turn. Memoized by config.yaml mtime in +# model_tools.get_tool_definitions(), so it rebuilds on switch. _NO_CAPABILITIES = {"modalities": ["text"], "max_reference_images": 0, "supports_upscale": False} def _active_image_capabilities() -> Dict[str, Any]: """Best-effort capabilities of the active backend/model; never raises. - Mirrors runtime dispatch: a set ``image_gen.provider`` asks that plugin, otherwise the - FAL catalog. Fail-closed: an undeclared capability is advertised as absent (an - under-declaring provider is that provider's bug, not a safety problem). + Mirrors runtime dispatch: a set ``image_gen.provider`` asks that plugin, else the FAL + catalog. Fail-closed: an undeclared capability is advertised as absent. """ info: Dict[str, Any] = dict(_NO_CAPABILITIES) - configured_provider = _read_configured_image_provider() if configured_provider and configured_provider != "fal": try: @@ -861,7 +816,6 @@ def _active_image_capabilities() -> Dict[str, Any]: return info except Exception: # noqa: BLE001 pass - # In-tree FAL path (provider unset or == "fal"). try: model_id, meta = _resolve_fal_model() @@ -870,8 +824,7 @@ def _active_image_capabilities() -> Dict[str, Any]: info["model"] = meta.get("display", model_id) info["modalities"] = ["text", "image"] if can_edit else ["text"] info["max_reference_images"] = int(meta.get("max_reference_images") or 1) if can_edit else 0 - # Clarity is a separate endpoint available on request for ANY catalog model - # (the per-model ``upscale`` key is only the default flag). + # Clarity is available on request for ANY catalog model (``upscale`` is only the default). info["supports_upscale"] = True except Exception: # noqa: BLE001 pass @@ -945,13 +898,8 @@ def _build_dynamic_image_schema() -> Dict[str, Any]: registry.register( - name="image_generate", - toolset="image_gen", - schema=IMAGE_GENERATE_SCHEMA, - handler=_handle_image_generate, - check_fn=check_image_generation_requirements, - requires_env=[], + name="image_generate", toolset="image_gen", schema=IMAGE_GENERATE_SCHEMA, + handler=_handle_image_generate, check_fn=check_image_generation_requirements, requires_env=[], is_async=False, # sync fal_client API to avoid "Event loop is closed" in gateway - emoji="🎨", - dynamic_schema_overrides=_build_dynamic_image_schema, + emoji="🎨", dynamic_schema_overrides=_build_dynamic_image_schema, ) diff --git a/tools/image_source.py b/tools/image_source.py index 5d73c0a100..b0a59712f4 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -163,11 +163,10 @@ def _media_cache_roots() -> list: def _permitted_host_read_target(p: Path, ctx: ResolveContext) -> Optional[Path]: - """Host path to read, or ``None`` if a host read is not permitted. + """Host path to read, or ``None`` (caller exec-reads inside the sandbox instead). - Local backend: any path. Non-local: only paths resolving inside a media cache root (a - container-visible cache path is first translated back to its host mount); anything else - returns ``None`` so the caller exec-reads inside the sandbox instead. + Local backend: any path. Non-local: only paths inside a media cache root (a + container-visible cache path is first translated back to its host mount). """ if _is_local_terminal_backend(): try: @@ -219,9 +218,8 @@ async def _resolve_container_fallback( path under a sandbox must never leak via a host fallback). Cold-start retry: under Docker the first exec against a fresh container can fail (empty - pipe) while an identical second call succeeds, so retry once after a short delay. On final - failure the container's own output is folded into the error so "no such file" / - "permission denied" / "never came up" are distinguishable. + pipe) while a second succeeds. On final failure the container's output is folded into the + error so "no such file" / "permission denied" / "never came up" are distinguishable. """ import shlex @@ -233,10 +231,9 @@ async def _resolve_container_fallback( f"session is available to read it", src=src, origin="container") - # Bound the read INSIDE the sandbox: head -c caps at ingest-limit+1 so /dev/zero can't - # stream unbounded base64 into host memory (the +1 distinguishes "at the cap" from - # "over"). The input redirect avoids argv, so leading-dash paths can't parse as options; - # base64 -w0 is GNU-only, hence tr -d for BusyBox. env.execute blocks — keep it off the loop. + # Bound the read INSIDE the sandbox: head -c caps at ingest-limit+1 (+1 distinguishes "at the + # cap" from "over") so /dev/zero can't stream unbounded base64 into host memory. The input + # redirect avoids argv (leading-dash paths); tr -d instead of GNU-only base64 -w0 (BusyBox). cmd = f"head -c {_MAX_INGEST_BYTES + 1} < {shlex.quote(str(p))} | base64 | tr -d '\\n'" last_res: dict = {"returncode": 1, "output": ""} @@ -263,12 +260,8 @@ async def _resolve_container_fallback( def _finalize( data: bytes, declared_mime: str, origin: str, src: str, permitted: tuple = ("image",) ) -> ResolvedImage: - """Chokepoint: 50MB ingest cap + type check. - - Images are typed by magic bytes. Video (opt-in) is typed by extension plus an mp4 - container sniff — sufficient because every downstream consumer re-validates, so a wrong - guess is a clean rejection there, not a hole here. - """ + """Chokepoint: 50MB ingest cap + type check. Images by magic bytes; video (opt-in) by + extension + mp4 sniff — enough because every downstream consumer re-validates.""" from tools.vision_tools import _detect_image_mime_type_from_bytes if len(data) > _MAX_INGEST_BYTES: @@ -310,10 +303,9 @@ async def resolve_local_source_to_data_url( ) -> str: """Convert a path-like media source into a ``data:`` URL via the resolver. - Dispatch-layer chokepoint for generation tools: providers historically read model-supplied - local paths off the HOST regardless of backend — broken under a sandbox and inconsistent - with vision's confinement. URL-shaped sources (http/https/data) pass through untouched. - Callers apply this only under a non-local backend (local keeps host reads). + Dispatch-layer chokepoint for generation tools so providers never read model-supplied paths + off the HOST under a sandbox. URL-shaped sources pass through; callers apply this only under + a non-local backend. """ s = (src or "").strip() if not s or s.lower().startswith(("http://", "https://", "data:")): From 60544819ab93bb0590d87a48561685b20d35d43e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:11:41 -0700 Subject: [PATCH 2/7] =?UTF-8?q?refactor(tools):=20image=20gen/source=20?= =?UTF-8?q?=E2=80=94=20squeeze=20body=20blank=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/fal_common.py | 5 ----- tools/image_generation_tool.py | 27 --------------------------- tools/image_source.py | 11 ----------- 3 files changed, 43 deletions(-) diff --git a/tools/fal_common.py b/tools/fal_common.py index daa1d37c01..fa72e9cb10 100644 --- a/tools/fal_common.py +++ b/tools/fal_common.py @@ -59,7 +59,6 @@ class _ManagedFalSyncClient: def __init__(self, fal_client: Any, *, key: str, queue_run_origin: str): sync_client_class = _require(getattr(fal_client, "SyncClient", None), "fal_client.SyncClient") client_module = _require(getattr(fal_client, "client", None), "fal_client.client") - self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin) self._sync_client = sync_client_class(key=key) self._http_client = getattr(self._sync_client, "_client", None) @@ -69,7 +68,6 @@ class _ManagedFalSyncClient: self._add_hint_header = getattr(client_module, "add_hint_header", None) self._add_priority_header = getattr(client_module, "add_priority_header", None) self._add_timeout_header = getattr(client_module, "add_timeout_header", None) - _require(self._http_client, "fal_client.SyncClient._client") if self._maybe_retry_request is None or self._raise_for_status is None: raise RuntimeError("fal_client.client request helpers are required for managed FAL gateway mode") @@ -85,7 +83,6 @@ class _ManagedFalSyncClient: url += "/" + path.lstrip("/") if webhook_url is not None: url += "?" + urlencode({"fal_webhook": webhook_url}) - request_headers = dict(headers or {}) if hint is not None and self._add_hint_header is not None: self._add_hint_header(hint, request_headers) @@ -97,13 +94,11 @@ class _ManagedFalSyncClient: if self._add_timeout_header is None: raise RuntimeError("fal_client.client.add_timeout_header is required for timeout requests") self._add_timeout_header(start_timeout, request_headers) - response = self._maybe_retry_request( self._http_client, "POST", url, json=arguments, timeout=getattr(self._sync_client, "default_timeout", 120.0), headers=request_headers, ) self._raise_for_status(response) - data = response.json() return self._request_handle_class( request_id=data["request_id"], response_url=data["response_url"], diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 913ab61601..35dee60876 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -81,7 +81,6 @@ def _resolve_managed_fal_gateway(): def _get_managed_fal_client(managed_gateway): """Reuse the managed FAL client so its internal httpx.Client is not leaked per call.""" global _managed_fal_client, _managed_fal_client_config - client_config = (managed_gateway.gateway_origin.rstrip("/"), managed_gateway.nous_user_token) with _managed_fal_client_lock: if _managed_fal_client is None or _managed_fal_client_config != client_config: @@ -105,7 +104,6 @@ def _wait_fal_result(handler, *, poll_seconds: float = 0.5): interrupt the worker is abandoned (remote job keeps running). """ from tools.interrupt import is_interrupted - result_box: list = [] error_box: list = [] def _get(): @@ -113,7 +111,6 @@ def _wait_fal_result(handler, *, poll_seconds: float = 0.5): result_box.append(handler.get()) except BaseException as exc: # noqa: BLE001 — re-raised on the caller thread error_box.append(exc) - worker = threading.Thread(target=_get, daemon=True, name="fal-result-wait") worker.start() while worker.is_alive(): @@ -134,7 +131,6 @@ def _submit_fal_request(model: str, arguments: Dict[str, Any]): managed_gateway = _resolve_managed_fal_gateway() if managed_gateway is None: return fal_client.submit(model, arguments=arguments, headers=request_headers) - try: return _get_managed_fal_client(managed_gateway).submit( model, arguments=arguments, headers=request_headers) @@ -220,14 +216,12 @@ def _build_payload(model_id, prompt, aspect_ratio, seed, overrides, image_urls=N aspect = (aspect_ratio or DEFAULT_ASPECT_RATIO).lower().strip() if aspect not in sizes: aspect = DEFAULT_ASPECT_RATIO - payload: Dict[str, Any] = dict(meta.get("defaults", {})) payload["prompt"] = (prompt or "").strip() required = {"prompt"} if edit: payload["image_urls"] = list(image_urls) required.add("image_urls") - size_key = _SIZE_KEY_BY_STYLE.get(meta["size_style"]) if size_key is None and not edit: raise ValueError(f"Unknown size_style: {meta['size_style']!r}") @@ -336,7 +330,6 @@ def _postprocess_image_generate_result(raw: str, task_id: str | None = None) -> image = payload.get("image") if not isinstance(image, str) or not _looks_like_absolute_file_path(image): return raw - env = _active_terminal_env(task_id) cache_base = _agent_cache_base_for_env(env) if not cache_base: @@ -384,12 +377,10 @@ def _prepare_fal_request(model_id, meta, prompt, aspect_ratio, seed, overrides, """Validate inputs and return ``(endpoint, arguments)``; raises ValueError with the user-facing message.""" if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("Prompt is required and must be a non-empty string") - # A stored-but-broken selection raises the selection-naming error from # _resolve_managed_fal_gateway(); only never-configured reports "no backend at all". if not (fal_key_is_configured() or _resolve_managed_fal_gateway()): raise ValueError(_build_no_backend_setup_message()) - edit_endpoint, display = meta.get("edit_endpoint"), meta.get("display", model_id) # Fail clearly rather than silently dropping sources and producing an unrelated picture. if source_images and not edit_endpoint: @@ -397,12 +388,10 @@ def _prepare_fal_request(model_id, meta, prompt, aspect_ratio, seed, overrides, f"Model '{display}' ({model_id}) is not capable of image-to-image / editing. " f"Provide a text-only prompt (omit image_url), or switch to an edit-capable model " f"via `hermes tools` → Image Generation.") - aspect_lc = (aspect_ratio or DEFAULT_ASPECT_RATIO).lower().strip() if aspect_lc not in VALID_ASPECT_RATIOS: logger.warning("Invalid aspect_ratio '%s', defaulting to '%s'", aspect_ratio, DEFAULT_ASPECT_RATIO) aspect_lc = DEFAULT_ASPECT_RATIO - if source_images: # Clamp reference count to the model's declared cap. max_refs = int(meta.get("max_reference_images") or 1) @@ -435,7 +424,6 @@ def image_generate_tool( source_images = [c.strip() for c in candidates if isinstance(c, str) and c.strip()] use_edit = bool(source_images) and bool(meta.get("edit_endpoint")) modality = "image" if use_edit else "text" - overrides: Dict[str, Any] = { "num_inference_steps": num_inference_steps, "guidance_scale": guidance_scale, "num_images": num_images, "output_format": output_format, @@ -453,31 +441,26 @@ def image_generate_tool( _debug.log_call("image_generate_tool", debug_call_data) _debug.save() return json.dumps(response, indent=2, ensure_ascii=False) - try: endpoint, arguments = _prepare_fal_request( model_id, meta, prompt, aspect_ratio, seed, {k: v for k, v in overrides.items() if v is not None}, source_images) result = _wait_fal_result(_submit_fal_request(endpoint, arguments=arguments)) generation_time = (datetime.datetime.now() - start_time).total_seconds() - if not result or "images" not in result: raise ValueError("Invalid response from FAL.ai API — no images returned") images = result.get("images", []) if not images: raise ValueError("No images were generated") - # Explicit ``upscale`` wins, including for edits; the catalog default never upscales # edits (Clarity is a text-to-image pass and must not silently alter compositions). if upscale is not None: should_upscale = bool(upscale) else: should_upscale = bool(meta.get("upscale", False)) and not use_edit - formatted_images = _format_images(images, should_upscale, prompt) if not formatted_images: raise ValueError("No valid image URLs returned from API") - upscaled_count = sum(1 for img in formatted_images if img.get("upscaled")) logger.info("Generated %s image(s) in %.1fs (%s upscaled) via %s [%s]", len(formatted_images), generation_time, upscaled_count, endpoint, modality) @@ -534,7 +517,6 @@ def _get_plugin_provider(name: str, *, force: bool = False): """Discover plugins (local import: importing this module must not trigger discovery) and return the named provider.""" from agent.image_gen_registry import get_provider from hermes_cli.plugins import _ensure_plugins_discovered - if force: _ensure_plugins_discovered(force=True) else: @@ -551,7 +533,6 @@ def check_image_generation_requirements() -> bool: return True except ImportError: pass - configured = _plugin_provider_name() if configured is None: return False @@ -654,7 +635,6 @@ def _dispatch_to_plugin_provider( return _provider_error( f"image_gen.provider='{configured}' is set but no plugin registered that name. " f"Run `hermes plugins list` to see available image gen backends.", "provider_not_registered") - pname = getattr(provider, "name", "?") kwargs: Dict[str, Any] = {"prompt": prompt, "aspect_ratio": aspect_ratio} try: @@ -719,7 +699,6 @@ def _maybe_route_managed_krea( return None if provider is None: return None - kwargs: Dict[str, Any] = {"prompt": prompt, "aspect_ratio": aspect_ratio, "model": normalized} try: _add_provider_kwargs(kwargs, image_url, reference_image_urls, upscale) @@ -739,13 +718,11 @@ def _confine_source_images(image_url, reference_image_urls, task_id, *, permitte """ if (os.getenv("TERMINAL_ENV") or "local").strip().lower() in ("", "local"): return image_url, reference_image_urls, None - from model_tools import _run_async from tools.image_source import ImageResolutionError, resolve_local_source_to_data_url def resolve(ref): return _run_async(resolve_local_source_to_data_url(ref, task_id, permitted=permitted)) - try: if isinstance(image_url, str) and image_url.strip(): image_url = resolve(image_url) @@ -764,13 +741,11 @@ def _handle_image_generate(args, **kw): aspect_ratio = args.get("aspect_ratio", DEFAULT_ASPECT_RATIO) upscale = args.get("upscale") task_id = kw.get("task_id") - # Confinement chokepoint BEFORE any dispatch: every route receives sandbox-confined bytes. image_url, reference_image_urls, confine_error = _confine_source_images( args.get("image_url"), args.get("reference_image_urls"), task_id) if confine_error is not None: return confine_error - # Order matters: explicit plugin provider (incl. "krea"), then model-driven managed Krea # interception (only when no provider is set, so BYO/direct FAL stays untouched), then FAL. sources = dict(image_url=image_url, reference_image_urls=reference_image_urls, @@ -865,7 +840,6 @@ def _build_dynamic_image_schema() -> Dict[str, Any]: info = _active_image_capabilities() except Exception: # noqa: BLE001 info = dict(_NO_CAPABILITIES) - max_refs = int(info.get("max_reference_images") or 0) can_edit = "image" in set(info.get("modalities") or ["text"]) static_props = IMAGE_GENERATE_SCHEMA["parameters"]["properties"] @@ -890,7 +864,6 @@ def _build_dynamic_image_schema() -> Dict[str, Any]: edit_clause = " (text-to-image only — the active model cannot edit existing images)" if info.get("supports_upscale"): properties["upscale"] = _UPSCALE_PARAM - return { "description": base_desc.format(edit_clause=edit_clause), "parameters": {"type": "object", "properties": properties, "required": ["prompt"]}, diff --git a/tools/image_source.py b/tools/image_source.py index b0a59712f4..bb36f8b2f2 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -71,7 +71,6 @@ async def resolve_image_source(src: str, ctx: ResolveContext, *, permitted: tupl "file path, a file:// URI, or a data: URL.", src=s, ) - # Everything else is a filesystem path — including bare relative names like "pic.png" # (a path-shape gate here regressed them once). candidate = s[len("file://"):] if s.lower().startswith("file://") else s @@ -117,7 +116,6 @@ def _http_block_reason(url: str) -> Optional[str]: ``_download_image`` re-checks per attempt and against the final redirect target (intentional).""" from tools.url_safety import is_safe_url from tools.website_policy import check_website_access - if not is_safe_url(url): return "blocked: unsafe or private URL" blocked = check_website_access(url) @@ -129,7 +127,6 @@ def _http_block_reason(url: str) -> Optional[str]: async def _download_to_bytes(url: str) -> bytes: import tempfile from tools.vision_tools import _download_image - with tempfile.NamedTemporaryFile(suffix=".img", delete=False) as tf: tmp = Path(tf.name) try: @@ -173,9 +170,7 @@ def _permitted_host_read_target(p: Path, ctx: ResolveContext) -> Optional[Path]: return p.resolve() except Exception: # noqa: BLE001 — unresolved path: let is_file() fail downstream return p - from tools.credential_files import from_agent_visible_cache_path - try: real = Path(from_agent_visible_cache_path(str(p))).resolve() except Exception: # noqa: BLE001 — cannot resolve -> not a safe host read @@ -222,7 +217,6 @@ async def _resolve_container_fallback( error so "no such file" / "permission denied" / "never came up" are distinguishable. """ import shlex - _ensure_container_env(ctx.task_id) env = _get_active_env(ctx.task_id) if env is None: @@ -230,12 +224,10 @@ async def _resolve_container_fallback( f"'{p}' is not reachable inside the sandbox and no active sandbox " f"session is available to read it", src=src, origin="container") - # Bound the read INSIDE the sandbox: head -c caps at ingest-limit+1 (+1 distinguishes "at the # cap" from "over") so /dev/zero can't stream unbounded base64 into host memory. The input # redirect avoids argv (leading-dash paths); tr -d instead of GNU-only base64 -w0 (BusyBox). cmd = f"head -c {_MAX_INGEST_BYTES + 1} < {shlex.quote(str(p))} | base64 | tr -d '\\n'" - last_res: dict = {"returncode": 1, "output": ""} for attempt in range(2): last_res = await asyncio.to_thread(env.execute, cmd) @@ -263,10 +255,8 @@ def _finalize( """Chokepoint: 50MB ingest cap + type check. Images by magic bytes; video (opt-in) by extension + mp4 sniff — enough because every downstream consumer re-validates.""" from tools.vision_tools import _detect_image_mime_type_from_bytes - if len(data) > _MAX_INGEST_BYTES: raise SourceTooLarge("media exceeds size limit", src=src, origin=origin) - sniffed = _detect_image_mime_type_from_bytes(data) if sniffed is not None: if "image" not in permitted: @@ -288,7 +278,6 @@ def _detect_video_mime(data: bytes, src: str) -> Optional[str]: offset 4 (covers extensionless data: URLs / query-string URLs).""" from urllib.parse import urlsplit from tools.vision_tools import _detect_video_mime_type - path_part = urlsplit(src).path if _SCHEME_RE.match(src) else src by_extension = _detect_video_mime_type(Path(path_part)) if by_extension is not None: From da621434084e5781f759e3b08ea785ef3fe62b47 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:15:55 -0700 Subject: [PATCH 3/7] =?UTF-8?q?refactor(tools):=20image=20gen/source/fal?= =?UTF-8?q?=5Fcommon=20=E2=80=94=20hug=20closing=20brackets=20outside=20sc?= =?UTF-8?q?hema=20dicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/fal_common.py | 6 ++--- tools/image_generation_tool.py | 42 ++++++++++++---------------------- tools/image_source.py | 12 ++++------ 3 files changed, 20 insertions(+), 40 deletions(-) diff --git a/tools/fal_common.py b/tools/fal_common.py index fa72e9cb10..4545e137bc 100644 --- a/tools/fal_common.py +++ b/tools/fal_common.py @@ -96,11 +96,9 @@ class _ManagedFalSyncClient: self._add_timeout_header(start_timeout, request_headers) response = self._maybe_retry_request( self._http_client, "POST", url, json=arguments, - timeout=getattr(self._sync_client, "default_timeout", 120.0), headers=request_headers, - ) + timeout=getattr(self._sync_client, "default_timeout", 120.0), headers=request_headers) self._raise_for_status(response) data = response.json() return self._request_handle_class( request_id=data["request_id"], response_url=data["response_url"], - status_url=data["status_url"], cancel_url=data["cancel_url"], client=self._http_client, - ) + status_url=data["status_url"], cancel_url=data["cancel_url"], client=self._http_client) diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 35dee60876..2e3ddf984f 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -42,8 +42,7 @@ from tools.image_generation_catalog import ( # noqa: F401 — re-exported (plug from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.tool_backend_helpers import ( NOUS_MANAGED_PROVIDER, fal_key_is_configured, managed_nous_tools_enabled, - nous_tool_gateway_unavailable_message, read_selection, selection_error, -) + nous_tool_gateway_unavailable_message, read_selection, selection_error) logger = logging.getLogger(__name__) @@ -67,8 +66,7 @@ def _resolve_managed_fal_gateway(): if gateway is None: raise ValueError(selection_error( "image_gen", NOUS_MANAGED_PROVIDER, - "the Nous Tool Gateway is not available (not entitled or unreachable)", - )) + "the Nous Tool Gateway is not available (not entitled or unreachable)")) return gateway if selected is not None: if fal_key_is_configured(): @@ -87,8 +85,7 @@ def _get_managed_fal_client(managed_gateway): # Resolved on this module so monkeypatching ``image_generation_tool.fal_client`` still applies. _managed_fal_client = _ManagedFalSyncClient( _load_fal_client(), key=managed_gateway.nous_user_token, - queue_run_origin=managed_gateway.gateway_origin, - ) + queue_run_origin=managed_gateway.gateway_origin) _managed_fal_client_config = client_config return _managed_fal_client @@ -116,8 +113,7 @@ def _wait_fal_result(handler, *, poll_seconds: float = 0.5): while worker.is_alive(): if is_interrupted(): raise ImageGenerationInterrupted( - "Image generation interrupted by user — abandoned the in-flight FAL job." - ) + "Image generation interrupted by user — abandoned the in-flight FAL job.") worker.join(timeout=poll_seconds) if error_box: raise error_box[0] @@ -142,8 +138,7 @@ def _submit_fal_request(model: str, arguments: Dict[str, Any]): gateway_message = "" if status in {401, 402, 403}: gateway_message = "\n\n" + nous_tool_gateway_unavailable_message( - "managed FAL image generation", force_fresh=True, - ) + "managed FAL image generation", force_fresh=True) raise ValueError( f"Nous Subscription gateway rejected model '{model}' (HTTP {status}). This model " f"may not yet be enabled on the Nous Portal's FAL proxy. Either:\n" @@ -254,8 +249,7 @@ def _upscale_image(image_url: str, original_prompt: str) -> Optional[Dict[str, A "creativity": UPSCALER_CREATIVITY, "resemblance": UPSCALER_RESEMBLANCE, "guidance_scale": UPSCALER_GUIDANCE_SCALE, "num_inference_steps": UPSCALER_NUM_INFERENCE_STEPS, - "enable_safety_checker": UPSCALER_SAFETY_CHECKER, - }) + "enable_safety_checker": UPSCALER_SAFETY_CHECKER}) result = _wait_fal_result(handler) if result and "image" in result: up = result["image"] @@ -263,8 +257,7 @@ def _upscale_image(image_url: str, original_prompt: str) -> Optional[Dict[str, A up.get("width", "unknown"), up.get("height", "unknown")) return { "url": up["url"], "width": up.get("width", 0), "height": up.get("height", 0), - "upscaled": True, "upscale_factor": UPSCALER_FACTOR, - } + "upscaled": True, "upscale_factor": UPSCALER_FACTOR} logger.error("Upscaler returned invalid response") return None except ImageGenerationInterrupted: @@ -368,8 +361,7 @@ def _format_images(images: list, should_upscale: bool, prompt: str) -> list: logger.warning("Using original image as fallback (upscale failed)") formatted.append({ "url": img["url"], "width": img.get("width", 0), "height": img.get("height", 0), - "upscaled": False, - }) + "upscaled": False}) return formatted @@ -411,8 +403,7 @@ def image_generate_tool( num_inference_steps: Optional[int] = None, guidance_scale: Optional[float] = None, num_images: Optional[int] = None, output_format: Optional[str] = None, seed: Optional[int] = None, image_url: Optional[str] = None, - reference_image_urls: Optional[list] = None, upscale: Optional[bool] = None, -) -> str: + reference_image_urls: Optional[list] = None, upscale: Optional[bool] = None) -> str: """Generate (or, with source images + an ``edit_endpoint`` model, edit) an image via FAL. Extra kwargs are overrides filtered per-model via ``supports`` / ``edit_supports`` (dropped @@ -426,14 +417,12 @@ def image_generate_tool( modality = "image" if use_edit else "text" overrides: Dict[str, Any] = { "num_inference_steps": num_inference_steps, "guidance_scale": guidance_scale, - "num_images": num_images, "output_format": output_format, - } + "num_images": num_images, "output_format": output_format} debug_call_data = { "model": model_id, "parameters": {"prompt": prompt, "aspect_ratio": aspect_ratio, **overrides, "seed": seed, "modality": modality, "source_images": len(source_images)}, - "error": None, "success": False, "images_generated": 0, "generation_time": 0, - } + "error": None, "success": False, "images_generated": 0, "generation_time": 0} start_time = datetime.datetime.now() def finish(generation_time: float, response: Dict[str, Any]) -> str: @@ -470,8 +459,7 @@ def image_generate_tool( "success": True, "image": formatted_images[0]["url"], "modality": modality, - "upscaled": bool(formatted_images[0].get("upscaled")), - }) + "upscaled": bool(formatted_images[0].get("upscaled"))}) except Exception as e: error_msg = f"Error generating image: {str(e)}" logger.error("%s", error_msg, exc_info=True) @@ -612,8 +600,7 @@ def _add_provider_kwargs(kwargs, image_url, reference_image_urls, upscale, model def _dispatch_to_plugin_provider( prompt: str, aspect_ratio: str, image_url: Optional[str] = None, - reference_image_urls: Optional[list] = None, upscale: Optional[bool] = None, -): + reference_image_urls: Optional[list] = None, upscale: Optional[bool] = None): """JSON result from the selected plugin provider, or ``None`` to fall through to in-tree FAL (provider unset / ``"fal"`` / ``"nous"``). Providers without ``upscale`` ignore it via ``**kwargs``.""" configured = _plugin_provider_name() @@ -672,8 +659,7 @@ def _normalize_krea_model(model_id: Optional[str]) -> Optional[str]: def _maybe_route_managed_krea( prompt: str, aspect_ratio: str, image_url: Optional[str] = None, - reference_image_urls: Optional[list] = None, upscale: Optional[bool] = None, -) -> Optional[str]: + reference_image_urls: Optional[list] = None, upscale: Optional[bool] = None) -> Optional[str]: """JSON result from the managed Krea gateway, or ``None`` to fall through. Fires only for a native ``krea-2-*`` model with no ``image_gen.provider`` other than diff --git a/tools/image_source.py b/tools/image_source.py index bb36f8b2f2..cdd28d6f63 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -69,8 +69,7 @@ async def resolve_image_source(src: str, ctx: ResolveContext, *, permitted: tupl raise UnsupportedScheme( "Unrecognized image source scheme. Use an http(s) URL, a local " "file path, a file:// URI, or a data: URL.", - src=s, - ) + src=s) # Everything else is a filesystem path — including bare relative names like "pic.png" # (a path-shape gate here regressed them once). candidate = s[len("file://"):] if s.lower().startswith("file://") else s @@ -149,8 +148,7 @@ def _is_local_terminal_backend() -> bool: _MEDIA_CACHE_SUBDIRS = ( "cache", # cache/images, cache/vision, cache/video(s), cache/audio "images", # desktop/clipboard/PDF uploads (tui_gateway) - "image_cache", "audio_cache", "video_cache", "temp_vision_images", "temp_video_files", -) + "image_cache", "audio_cache", "video_cache", "temp_vision_images", "temp_video_files") def _media_cache_roots() -> list: @@ -207,8 +205,7 @@ def _ensure_container_env(task_id: Optional[str]) -> None: async def _resolve_container_fallback( - p: Path, ctx: ResolveContext, src: str, permitted: tuple = ("image",) -) -> ResolvedImage: + p: Path, ctx: ResolveContext, src: str, permitted: tuple = ("image",)) -> ResolvedImage: """Read the bytes inside the sandbox; fail-closed when no env exists (a non-cache host path under a sandbox must never leak via a host fallback). @@ -288,8 +285,7 @@ def _detect_video_mime(data: bytes, src: str) -> Optional[str]: async def resolve_local_source_to_data_url( - src: str, task_id: Optional[str], *, permitted: tuple = ("image",) -) -> str: + src: str, task_id: Optional[str], *, permitted: tuple = ("image",)) -> str: """Convert a path-like media source into a ``data:`` URL via the resolver. Dispatch-layer chokepoint for generation tools so providers never read model-supplied paths From 4b691d2ca8be19664990c64a3d009a1db86998f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:21:35 -0700 Subject: [PATCH 4/7] =?UTF-8?q?refactor(tools):=20image=20gen/source=20?= =?UTF-8?q?=E2=80=94=20drop=20redundant=20never-raises=20guards,=20extract?= =?UTF-8?q?=20credential=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/image_generation_tool.py | 39 ++++++++++++---------------------- tools/image_source.py | 36 +++++++++++++++---------------- 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 2e3ddf984f..23a33f0feb 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -489,8 +489,7 @@ def _build_no_backend_setup_message() -> str: lines.append(" - FAL_KEY is not set and the managed FAL gateway is unreachable") else: lines.append(" - FAL_KEY environment variable is not set") - gateway_message = nous_tool_gateway_unavailable_message("managed FAL image generation") - if gateway_message: + if gateway_message := nous_tool_gateway_unavailable_message("managed FAL image generation"): lines.append(f" - {gateway_message}") lines += ["", "To enable image generation, do one of:", " 1. Get a free API key at https://fal.ai and set FAL_KEY= (then restart the session)"] @@ -777,18 +776,15 @@ def _active_image_capabilities() -> Dict[str, Any]: return info except Exception: # noqa: BLE001 pass - # In-tree FAL path (provider unset or == "fal"). - try: - model_id, meta = _resolve_fal_model() - can_edit = bool(meta.get("edit_endpoint")) - info["provider"] = "FAL.ai" - info["model"] = meta.get("display", model_id) - info["modalities"] = ["text", "image"] if can_edit else ["text"] - info["max_reference_images"] = int(meta.get("max_reference_images") or 1) if can_edit else 0 - # Clarity is available on request for ANY catalog model (``upscale`` is only the default). - info["supports_upscale"] = True - except Exception: # noqa: BLE001 - pass + # In-tree FAL path (provider unset or == "fal"); _resolve_fal_model() never raises. + model_id, meta = _resolve_fal_model() + can_edit = bool(meta.get("edit_endpoint")) + info["provider"] = "FAL.ai" + info["model"] = meta.get("display", model_id) + info["modalities"] = ["text", "image"] if can_edit else ["text"] + info["max_reference_images"] = int(meta.get("max_reference_images") or 1) if can_edit else 0 + # Clarity is available on request for ANY catalog model (``upscale`` is only the default). + info["supports_upscale"] = True return info @@ -822,16 +818,11 @@ def _build_dynamic_image_schema() -> Dict[str, Any]: "file path; reference it in your response using the current " "platform's file-delivery convention." ) - try: - info = _active_image_capabilities() - except Exception: # noqa: BLE001 - info = dict(_NO_CAPABILITIES) + info = _active_image_capabilities() max_refs = int(info.get("max_reference_images") or 0) can_edit = "image" in set(info.get("modalities") or ["text"]) static_props = IMAGE_GENERATE_SCHEMA["parameters"]["properties"] - properties: Dict[str, Any] = { - "prompt": static_props["prompt"], "aspect_ratio": static_props["aspect_ratio"], - } + properties: Dict[str, Any] = {"prompt": static_props["prompt"], "aspect_ratio": static_props["aspect_ratio"]} if can_edit: edit_clause = ", or edit / transform an existing image by passing image_url" properties["image_url"] = _IMAGE_URL_PARAM @@ -850,10 +841,8 @@ def _build_dynamic_image_schema() -> Dict[str, Any]: edit_clause = " (text-to-image only — the active model cannot edit existing images)" if info.get("supports_upscale"): properties["upscale"] = _UPSCALE_PARAM - return { - "description": base_desc.format(edit_clause=edit_clause), - "parameters": {"type": "object", "properties": properties, "required": ["prompt"]}, - } + return {"description": base_desc.format(edit_clause=edit_clause), + "parameters": {"type": "object", "properties": properties, "required": ["prompt"]}} registry.register( diff --git a/tools/image_source.py b/tools/image_source.py index cdd28d6f63..9f14bfd947 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -76,17 +76,7 @@ async def resolve_image_source(src: str, ctx: ResolveContext, *, permitted: tupl p = Path(os.path.expanduser(candidate)) host_target = _permitted_host_read_target(p, ctx) if host_target is not None and host_target.is_file(): - # Shared credential-read guard: refuse secret-bearing files (.env, auth.json) with a - # specific error. Guard import is best-effort; a real block always propagates. - try: - from agent.file_safety import raise_if_read_blocked - except Exception: # noqa: BLE001 — guard unavailable: proceed - raise_if_read_blocked = None - if raise_if_read_blocked is not None: - try: - raise_if_read_blocked(str(host_target)) - except ValueError as exc: - raise SourceUnsafe(str(exc), src=s, origin="file") + _guard_credential_read(host_target, s) data = await asyncio.to_thread(host_target.read_bytes) return _finalize(data, "", "file", s, permitted) if _is_local_terminal_backend(): @@ -95,6 +85,19 @@ async def resolve_image_source(src: str, ctx: ResolveContext, *, permitted: tupl return await _resolve_container_fallback(p, ctx, s, permitted) +def _guard_credential_read(host_target: Path, src: str) -> None: + """Shared credential-read guard: refuse secret-bearing files (.env, auth.json) with a specific + error. Guard import is best-effort; a real block always propagates.""" + try: + from agent.file_safety import raise_if_read_blocked + except Exception: # noqa: BLE001 — guard unavailable: proceed + return + try: + raise_if_read_blocked(str(host_target)) + except ValueError as exc: + raise SourceUnsafe(str(exc), src=src, origin="file") + + def _resolve_data_url(s: str) -> tuple[bytes, str]: header, _, payload = s.partition(",") if ";base64" not in header: @@ -117,8 +120,7 @@ def _http_block_reason(url: str) -> Optional[str]: from tools.website_policy import check_website_access if not is_safe_url(url): return "blocked: unsafe or private URL" - blocked = check_website_access(url) - if blocked: + if blocked := check_website_access(url): return blocked.get("message") or "blocked by website policy" return None @@ -173,12 +175,8 @@ def _permitted_host_read_target(p: Path, ctx: ResolveContext) -> Optional[Path]: real = Path(from_agent_visible_cache_path(str(p))).resolve() except Exception: # noqa: BLE001 — cannot resolve -> not a safe host read return None - for root in _media_cache_roots(): - try: - real.relative_to(root.resolve()) - return real - except ValueError: - continue + if any(real.is_relative_to(root.resolve()) for root in _media_cache_roots()): + return real return None From d14c61a4e2239b9941cfe0c5937271f762ea642b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:25:42 -0700 Subject: [PATCH 5/7] =?UTF-8?q?refactor(tools):=20image=20gen=20=E2=80=94?= =?UTF-8?q?=20merge=20provider=20exception=20branches,=20single=20Krea=20p?= =?UTF-8?q?robe=20try;=20compact=20image=5Fsource=20module=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/image_generation_tool.py | 27 +++++++++------------------ tools/image_source.py | 15 ++++++--------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 23a33f0feb..8edb887abb 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -29,11 +29,8 @@ def _load_fal_client() -> Any: from tools.debug_helpers import DebugSession -from tools.fal_common import ( - _ManagedFalSyncClient, - _extract_http_status, - _normalize_fal_queue_url_format, # noqa: F401 — re-exported for tests -) +from tools.fal_common import ( # noqa: F401 — _normalize_fal_queue_url_format re-exported for tests + _ManagedFalSyncClient, _extract_http_status, _normalize_fal_queue_url_format) from tools.image_generation_catalog import ( # noqa: F401 — re-exported (plugins/tests/tools_config) DEFAULT_ASPECT_RATIO, DEFAULT_MODEL, FAL_MODELS, UPSCALER_CREATIVITY, UPSCALER_DEFAULT_PROMPT, UPSCALER_FACTOR, UPSCALER_GUIDANCE_SCALE, UPSCALER_MODEL, UPSCALER_NEGATIVE_PROMPT, @@ -626,10 +623,11 @@ def _dispatch_to_plugin_provider( try: _add_provider_kwargs(kwargs, image_url, reference_image_urls, upscale, model=_read_configured_image_model()) result = provider.generate(**kwargs) - except TypeError as exc: - # generate() predating image_url support (third-party plugin not yet updated): - # text-to-image keeps working; surface a clear note when an edit was requested. - if "image_url" in kwargs or "reference_image_urls" in kwargs: + except Exception as exc: + # A TypeError from generate() predating image_url support (third-party plugin not yet + # updated): text-to-image keeps working; surface a clear note when an edit was requested. + is_type_error = isinstance(exc, TypeError) + if is_type_error and ("image_url" in kwargs or "reference_image_urls" in kwargs): logger.warning("image_gen provider '%s' rejected image-to-image kwargs " "(signature too narrow): %s", pname, exc) return _provider_error( @@ -637,10 +635,7 @@ def _dispatch_to_plugin_provider( f"signature is out of date with the image_generate schema). Omit image_url for " f"text-to-image, or pick a backend that supports editing via `hermes tools` → " f"Image Generation.", "modality_unsupported") - logger.warning("Image gen provider '%s' raised TypeError: %s", pname, exc) - return _provider_error(f"Provider '{pname}' error: {exc}", "provider_exception") - except Exception as exc: - logger.warning("Image gen provider '%s' raised: %s", pname, exc) + logger.warning("Image gen provider '%s' raised%s: %s", pname, " TypeError" if is_type_error else "", exc) return _provider_error(f"Provider '{pname}' error: {exc}", "provider_exception") return _provider_result(result, "Provider returned a non-dict result") @@ -674,13 +669,9 @@ def _maybe_route_managed_krea( from plugins.image_gen.krea import _resolve_managed_krea_gateway if _resolve_managed_krea_gateway() is None: return None - except Exception as exc: # noqa: BLE001 - logger.debug("Managed Krea routing probe failed: %s", exc) - return None - try: provider = _get_plugin_provider("krea") except Exception as exc: # noqa: BLE001 - logger.debug("Managed Krea routing: provider unavailable: %s", exc) + logger.debug("Managed Krea routing unavailable: %s", exc) return None if provider is None: return None diff --git a/tools/image_source.py b/tools/image_source.py index 9f14bfd947..ee42a5b3e4 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -1,13 +1,10 @@ -"""Single resolver for every media source -> bytes + mime. +"""Single resolver for every media source (data:/http(s)/file/local/container) -> bytes + mime. -All source handling (data:/http(s)/file/local/container) funnels through -:func:`resolve_image_source` so size and magic-byte checks are enforced exactly once. -Images are the default; callers whose argument takes video opt in via ``permitted=("video",)``. - -Security (terminal-backend confinement, GHSA-gpxw-6wxv-w3qq): under a non-local backend -vision is confined like the file tools: local -> read any host path; non-local -> host-read -only inside a media cache (bind-mounted into the sandbox), anything else is exec-read -*inside the sandbox*, so an injected ``vision_analyze('/etc/passwd')`` never reads the host. +Everything funnels through :func:`resolve_image_source` so size and magic-byte checks run +exactly once. Images are the default; video callers opt in via ``permitted=("video",)``. +Security (GHSA-gpxw-6wxv-w3qq): under a non-local backend vision is confined like the file +tools — host-read only inside a media cache (bind-mounted into the sandbox), anything else is +exec-read *inside the sandbox*, so ``vision_analyze('/etc/passwd')`` never reads the host. """ from __future__ import annotations From b075e9b3a8160987541681477c3004b392ffd097 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:27:24 -0700 Subject: [PATCH 6/7] =?UTF-8?q?refactor(tools):=20image=20gen/source=20?= =?UTF-8?q?=E2=80=94=20wrap=20introduced=20>110-col=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/image_generation_tool.py | 28 ++++++++++++++++++---------- tools/image_source.py | 3 ++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 8edb887abb..a87ac04e81 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -190,7 +190,8 @@ def _resolve_fal_model() -> tuple: return model_id, FAL_MODELS[model_id] -_SIZE_KEY_BY_STYLE = {"image_size_preset": "image_size", "gpt_literal": "image_size", "aspect_ratio": "aspect_ratio"} +_SIZE_KEY_BY_STYLE = {"image_size_preset": "image_size", "gpt_literal": "image_size", + "aspect_ratio": "aspect_ratio"} def _build_payload(model_id, prompt, aspect_ratio, seed, overrides, image_urls=None) -> Dict[str, Any]: @@ -230,7 +231,8 @@ def _build_fal_payload(model_id, prompt, aspect_ratio=DEFAULT_ASPECT_RATIO, seed return _build_payload(model_id, prompt, aspect_ratio, seed, overrides) -def _build_fal_edit_payload(model_id, prompt, image_urls, aspect_ratio=DEFAULT_ASPECT_RATIO, seed=None, overrides=None): +def _build_fal_edit_payload(model_id, prompt, image_urls, aspect_ratio=DEFAULT_ASPECT_RATIO, + seed=None, overrides=None): """FAL *edit* (image-to-image) payload: ``image_urls`` + prompt, filtered to ``edit_supports``.""" return _build_payload(model_id, prompt, aspect_ratio, seed, overrides, image_urls=image_urls) @@ -408,8 +410,8 @@ def image_generate_tool( "error", "error_type"}``. """ model_id, meta = _resolve_fal_model() - candidates = [image_url, *(reference_image_urls if isinstance(reference_image_urls, (list, tuple)) else [])] - source_images = [c.strip() for c in candidates if isinstance(c, str) and c.strip()] + refs = reference_image_urls if isinstance(reference_image_urls, (list, tuple)) else [] + source_images = [c.strip() for c in (image_url, *refs) if isinstance(c, str) and c.strip()] use_edit = bool(source_images) and bool(meta.get("edit_endpoint")) modality = "image" if use_edit else "text" overrides: Dict[str, Any] = { @@ -462,7 +464,8 @@ def image_generate_tool( logger.error("%s", error_msg, exc_info=True) debug_call_data["error"] = error_msg generation_time = (datetime.datetime.now() - start_time).total_seconds() - return finish(generation_time, {"success": False, "image": None, "error": str(e), "error_type": type(e).__name__}) + return finish(generation_time, + {"success": False, "image": None, "error": str(e), "error_type": type(e).__name__}) def check_fal_api_key() -> bool: @@ -489,9 +492,11 @@ def _build_no_backend_setup_message() -> str: if gateway_message := nous_tool_gateway_unavailable_message("managed FAL image generation"): lines.append(f" - {gateway_message}") lines += ["", "To enable image generation, do one of:", - " 1. Get a free API key at https://fal.ai and set FAL_KEY= (then restart the session)"] + " 1. Get a free API key at https://fal.ai and set FAL_KEY= " + "(then restart the session)"] if managed: - lines.append(" 2. Sign in to a Nous account that has the managed FAL gateway enabled (`hermes setup`)") + lines.append(" 2. Sign in to a Nous account that has the managed FAL gateway enabled " + "(`hermes setup`)") lines.append(" 3. Configure a different image_gen provider via `hermes tools` → Image Generation " "(run `hermes plugins list` to see installed backends)") return "\n".join(lines) @@ -621,7 +626,8 @@ def _dispatch_to_plugin_provider( pname = getattr(provider, "name", "?") kwargs: Dict[str, Any] = {"prompt": prompt, "aspect_ratio": aspect_ratio} try: - _add_provider_kwargs(kwargs, image_url, reference_image_urls, upscale, model=_read_configured_image_model()) + _add_provider_kwargs(kwargs, image_url, reference_image_urls, upscale, + model=_read_configured_image_model()) result = provider.generate(**kwargs) except Exception as exc: # A TypeError from generate() predating image_url support (third-party plugin not yet @@ -635,7 +641,8 @@ def _dispatch_to_plugin_provider( f"signature is out of date with the image_generate schema). Omit image_url for " f"text-to-image, or pick a backend that supports editing via `hermes tools` → " f"Image Generation.", "modality_unsupported") - logger.warning("Image gen provider '%s' raised%s: %s", pname, " TypeError" if is_type_error else "", exc) + logger.warning("Image gen provider '%s' raised%s: %s", pname, + " TypeError" if is_type_error else "", exc) return _provider_error(f"Provider '{pname}' error: {exc}", "provider_exception") return _provider_result(result, "Provider returned a non-dict result") @@ -813,7 +820,8 @@ def _build_dynamic_image_schema() -> Dict[str, Any]: max_refs = int(info.get("max_reference_images") or 0) can_edit = "image" in set(info.get("modalities") or ["text"]) static_props = IMAGE_GENERATE_SCHEMA["parameters"]["properties"] - properties: Dict[str, Any] = {"prompt": static_props["prompt"], "aspect_ratio": static_props["aspect_ratio"]} + properties: Dict[str, Any] = { + "prompt": static_props["prompt"], "aspect_ratio": static_props["aspect_ratio"]} if can_edit: edit_clause = ", or edit / transform an existing image by passing image_url" properties["image_url"] = _IMAGE_URL_PARAM diff --git a/tools/image_source.py b/tools/image_source.py index ee42a5b3e4..70d28a8db1 100644 --- a/tools/image_source.py +++ b/tools/image_source.py @@ -50,7 +50,8 @@ class ResolvedImage: _SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://") -async def resolve_image_source(src: str, ctx: ResolveContext, *, permitted: tuple = ("image",)) -> ResolvedImage: +async def resolve_image_source( + src: str, ctx: ResolveContext, *, permitted: tuple = ("image",)) -> ResolvedImage: if not isinstance(src, str) or not src.strip(): raise SourceNotFound("image_url is required", src=str(src)) s = src.strip() From cb4e5355b86c3befe4a44ce69aa6a52f29c17482 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:30:06 -0700 Subject: [PATCH 7/7] =?UTF-8?q?refactor(tools):=20fal=5Fcommon=20=E2=80=94?= =?UTF-8?q?=20inline=20required-attr=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/fal_common.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tools/fal_common.py b/tools/fal_common.py index 4545e137bc..c43881ef11 100644 --- a/tools/fal_common.py +++ b/tools/fal_common.py @@ -61,17 +61,16 @@ class _ManagedFalSyncClient: client_module = _require(getattr(fal_client, "client", None), "fal_client.client") self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin) self._sync_client = sync_client_class(key=key) - self._http_client = getattr(self._sync_client, "_client", None) + self._http_client = _require(getattr(self._sync_client, "_client", None), "fal_client.SyncClient._client") self._maybe_retry_request = getattr(client_module, "_maybe_retry_request", None) self._raise_for_status = getattr(client_module, "_raise_for_status", None) - self._request_handle_class = getattr(client_module, "SyncRequestHandle", None) + if self._maybe_retry_request is None or self._raise_for_status is None: + raise RuntimeError("fal_client.client request helpers are required for managed FAL gateway mode") + self._request_handle_class = _require( + getattr(client_module, "SyncRequestHandle", None), "fal_client.client.SyncRequestHandle") self._add_hint_header = getattr(client_module, "add_hint_header", None) self._add_priority_header = getattr(client_module, "add_priority_header", None) self._add_timeout_header = getattr(client_module, "add_timeout_header", None) - _require(self._http_client, "fal_client.SyncClient._client") - if self._maybe_retry_request is None or self._raise_for_status is None: - raise RuntimeError("fal_client.client request helpers are required for managed FAL gateway mode") - _require(self._request_handle_class, "fal_client.client.SyncRequestHandle") def submit( self, application: str, arguments: Dict[str, Any], *, path: str = "",