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

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:22 +00:00
450 changed files with 60479 additions and 11016 deletions
+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()
-6
View File
@@ -496,7 +496,6 @@ def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional
"content": clean,
"version": 1,
"source_doc_id": getattr(source_doc, "id", None),
"exit_code": 0,
}
@@ -619,7 +618,6 @@ class CreateDocumentTool:
"language": language,
"content": content,
"version": 1,
"exit_code": 0,
}
except Exception as e:
db.rollback()
@@ -710,7 +708,6 @@ class UpdateDocumentTool:
"language": doc.language,
"content": new_content,
"version": new_ver,
"exit_code": 0,
}
except Exception as e:
db.rollback()
@@ -866,7 +863,6 @@ class EditDocumentTool:
"version": new_ver,
"applied": applied,
"skipped": skipped,
"exit_code": 0,
}
return {"error": "No edits applied — FIND text cannot be blank"}
@@ -972,7 +968,6 @@ class EditDocumentTool:
"version": new_ver,
"applied": applied,
"skipped": skipped,
"exit_code": 0,
}
if partial_edits:
result.update({
@@ -1064,7 +1059,6 @@ class SuggestDocumentTool:
"partial": bool(invalid),
"rejected": len(invalid),
"invalid_suggestions": invalid,
"exit_code": 0,
}
finally:
db.close()
+646
View File
@@ -0,0 +1,646 @@
"""In-process agent tools for the scoped ``photo_editor`` capability family.
These handlers run as the logged-in owner and drive the image editor over the
owner-scoped in-process bridge (``/api/editor/bridge/*``) plus the app's
existing image transforms (``/api/image/*``). They are only offered to the
model when the ``photo_editor`` family is active (the Quick Edit popup or an
explicit photo-edit chat turn), never in the general tool list.
Loopback calls carry the internal token and impersonate ``owner`` (middleware
app.py), so the editor bridge resolves the same ``owner:session`` scope the
browser subscriber uses.
"""
from __future__ import annotations
import base64
import hashlib
import io
import json
import logging
import re
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
from src.tools._common import _INTERNAL_BASE, _internal_headers
logger = logging.getLogger(__name__)
def _provider_message() -> str:
try:
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
return IMAGE_PROVIDER_REQUIRED_MESSAGE
except Exception:
return (
"Removing/adding objects needs an image generation provider; enable one "
"in Settings > Models."
)
def _provider_available(owner: Optional[str]) -> bool:
try:
from src.ai_interaction import image_editing_provider_available
return bool(image_editing_provider_available(owner))
except Exception:
return True
# Local diffusion/image servers can take 90-110s per image (8 steps) and
# longer on a cold start; the previous 120s default timed out mid-generation.
_EDITOR_TIMEOUT = 300.0
# A mask covering less than this fraction of the image cannot drive a useful
# regional inpaint (tiny figures, empty SAM output): fall back to grounding or
# whole-image instruction mode instead.
_MIN_MASK_FRACTION = 0.002
_AI_OPS = {
"remove-bg": "/api/image/remove-bg",
"upscale": "/api/image/upscale-local",
"denoise": "/api/image/denoise",
"enhance-face": "/api/image/enhance-face",
"inpaint": "/api/image/inpaint",
"generative": "/api/image/inpaint",
}
_PLACE_OP = "layers.placeImage"
def _parse(content: Any) -> Dict[str, Any]:
raw = content if isinstance(content, str) else json.dumps(content or {})
try:
parsed = json.loads(raw) if raw.strip() else {}
except (ValueError, TypeError):
parsed = {}
return parsed if isinstance(parsed, dict) else {}
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
headers = {**_internal_headers(owner), "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(f"{_INTERNAL_BASE}{path}", json=payload, headers=headers)
resp.raise_for_status()
try:
return resp.json()
except ValueError:
return {"raw": resp.text}
def _as_data_url(value: Any) -> Optional[str]:
if not isinstance(value, str) or not value:
return None
if value.startswith("data:"):
return value
if value.startswith("/"):
return None
return f"data:image/png;base64,{value}"
def _data_url_bytes(data_url: str) -> bytes:
_, _, b64 = data_url.partition(",")
return base64.b64decode(b64)
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
from core.database import GalleryImage, SessionLocal
from src.constants import GENERATED_IMAGES_DIR
db = SessionLocal()
try:
root = Path(GENERATED_IMAGES_DIR).resolve()
root.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
(root / filename).write_bytes(image_bytes)
new_id = str(uuid.uuid4())
width = height = 0
try:
from PIL import Image
with Image.open(io.BytesIO(image_bytes)) as img:
width, height = img.size
except Exception:
pass
size = f"{width}x{height}" if width and height else ""
# The editor bridge uses an owner-scoped session id (e.g. "pewds:3")
# that is not a chat-sessions row. GalleryImage.session_id is a FK to
# sessions.id, so drop an unknown id instead of raising IntegrityError.
if session_id:
from core.database import Session as _ChatSession
try:
known = db.query(_ChatSession.id).filter(_ChatSession.id == session_id).first()
except Exception:
known = None
if known is None:
session_id = None
derived = GalleryImage(
id=new_id,
filename=filename,
prompt=prompt or None,
caption=None,
model=model,
size=size or None,
quality=None,
tags=None,
ai_tags=None,
session_id=session_id,
album_id=None,
owner=owner,
file_hash=hashlib.sha256(image_bytes).hexdigest(),
file_size=len(image_bytes),
width=width or None,
height=height or None,
)
db.add(derived)
db.commit()
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
except Exception:
db.rollback()
raise
finally:
db.close()
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool:
params: Dict[str, Any] = {
"dataUrl": data_url,
"name": name,
"masked": bool(masked),
"blend": blend or "normal",
"opacity": opacity,
}
# Forward the edit mode so the client can build a difference mask for
# instruction-mode results: the whole image was regenerated under an
# instruction prompt, so untouched pixels must stay pixel-identical (the
# client keeps only the changed region) instead of the new layer overwriting
# the entire canvas. Omit the keys entirely when no mode is given so the
# client keeps its current behavior (e.g. generate_image must not diff-mask).
if mode:
params["mode"] = mode
params["differenceMask"] = mode == "instruction"
if mode == "instruction":
# Exclude a thin edge band from the client difference mask: full-frame
# regenerations can shift pixels along the border.
params["edgeExcludePx"] = 3
payload = {
"session": session,
"op": _PLACE_OP,
"params": params,
"wait": True,
"timeout": 60,
}
try:
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
return data.get("ok") is not False
except Exception as exc: # pragma: no cover - defensive
logger.warning("editor layer placement failed: %s", exc)
return False
async def _fetch_selection_mask(owner: Optional[str], session: str) -> Optional[str]:
"""Return the editor's current selection as a data-URL mask, or None.
Generative ops (inpaint/generative) need a mask; the planner/judge may not
have supplied one, so fall back to whatever is selected in the editor.
"""
payload = {
"session": session,
"op": "selection.exportMask",
"params": {},
"wait": True,
"timeout": 30,
}
try:
data = await _post("/api/editor/bridge/command", payload, owner, timeout=45)
except Exception as exc: # pragma: no cover - defensive
logger.warning("editor selection mask fetch failed: %s", exc)
return None
result = data.get("result") if isinstance(data, dict) else None
if isinstance(result, dict):
return _as_data_url(result.get("mask"))
return None
def _mask_white_fraction(data_url: Optional[str]) -> float:
"""Fraction of a grayscale mask that is selected (white, >=128)."""
if not data_url:
return 0.0
try:
from PIL import Image as _PILImage
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))).convert("L") as im:
total = im.width * im.height
if not total:
return 0.0
hist = im.histogram()
return sum(hist[128:]) / total
except Exception:
return 0.0
def _image_is_blank(data_url: str) -> bool:
"""True when a result has an alpha channel that is essentially transparent."""
try:
from PIL import Image as _PILImage
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))) as im:
if im.mode not in ("RGBA", "LA") and "transparency" not in im.info:
return False
alpha = im.convert("RGBA").getchannel("A")
hist = alpha.histogram()
total = sum(hist)
return total > 0 and (sum(hist[1:]) / total) < 0.001
except Exception:
return False
def _rect_mask_data_url(image_data_url: str, box: List[float], *, pad_frac: float = 0.15) -> Optional[str]:
"""Build a rectangular mask data URL from a [x1,y1,x2,y2] box, dilated.
Used when the grounding detector finds an object but SAM returns an empty
mask (tiny figures): a dilated box guarantees a usable region.
"""
try:
from PIL import Image as _PILImage, ImageDraw
with _PILImage.open(io.BytesIO(_data_url_bytes(image_data_url))) as im:
w, h = im.size
x1, y1, x2, y2 = (float(v) for v in box)
bw, bh = x2 - x1, y2 - y1
x1, y1 = max(0.0, x1 - bw * pad_frac), max(0.0, y1 - bh * pad_frac)
x2, y2 = min(float(w), x2 + bw * pad_frac), min(float(h), y2 + bh * pad_frac)
mask = _PILImage.new("L", (w, h), 0)
ImageDraw.Draw(mask).rectangle([x1, y1, x2, y2], fill=255)
buf = io.BytesIO()
mask.save(buf, "PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
async def _ground_box(owner: Optional[str], image_data_url: str, label: str) -> Optional[List[float]]:
"""Use the in-app grounding detector (OWL-ViT via /api/image/mask) for a box."""
try:
data = await _post("/api/image/mask", {"image": image_data_url, "text": label}, owner, timeout=90)
except Exception as exc: # pragma: no cover - defensive
logger.warning("editor grounding box failed: %s", exc)
return None
box = data.get("bbox") if isinstance(data, dict) else None
if isinstance(box, (list, tuple)) and len(box) == 4:
try:
return [float(v) for v in box]
except Exception:
return None
return None
def _ensure_keep_clause(text: str) -> str:
"""Append a keep-the-subject clause when the prompt lacks one."""
body = str(text or "").strip()
if not body:
return body
if re.search(r"\b(keep|preserve|leave|unchanged|identical|same)\b", body, re.I):
return body
return body.rstrip(". ") + ". Keep the main subject and everything else in the image exactly the same."
def _mask_label(args: Dict[str, Any], prompt: str) -> str:
"""Best-effort object noun for grounding a regional removal."""
label = str(args.get("label") or args.get("object") or "").strip()
if label:
return label
m = re.search(
r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out)\s+(?:the\s+|a\s+|an\s+)?"
r"([a-z][a-z \-]{1,30}?)(?:\s+(?:on|in|at|from|to|near|behind|beside|by)\b|[.,;!?]|$)",
prompt or "",
re.I,
)
if m:
noun = m.group(1).strip().lower()
if noun and noun not in {"background", "bg"}:
return noun
for noun in ("person", "people", "man", "woman", "car", "dog", "sign", "logo"):
if re.search(rf"\b{re.escape(noun)}\b", prompt or "", re.I):
return "person" if noun in {"person", "people", "man", "woman"} else noun
return "object"
class PlanEditTool:
"""Plan an image edit as ordered editor operations (no side effects)."""
async def execute(self, content: Any, ctx: Dict[str, Any]):
args = _parse(content)
prompt = str(args.get("prompt") or "").strip()
if not prompt:
return "plan_edit: invalid", {"error": "plan_edit needs a non-empty `prompt`.", "exit_code": 1}
state = args.get("state") if isinstance(args.get("state"), dict) else {}
try:
data = await _post(
"/api/editor/plan",
{"prompt": prompt, "state": state, "mode": "plan", "session": _session(args, ctx)},
ctx.get("owner"),
)
except Exception as exc:
return "plan_edit: error", {"error": f"plan_edit failed: {exc}", "exit_code": 1}
steps = data.get("steps") or []
reply = str(data.get("reply") or "")
return f"plan_edit: {len(steps)} step(s)", {
"output": reply or f"Planned {len(steps)} editor step(s).",
"reply": reply,
"planner": data.get("planner"),
"steps": steps,
"exit_code": 0,
}
class RunEditorCommandTool:
"""Run a single validated editor op in the browser via the owner bridge."""
async def execute(self, content: Any, ctx: Dict[str, Any]):
args = _parse(content)
op = str(args.get("op") or "").strip()
if not op:
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
params = args.get("params") if isinstance(args.get("params"), dict) else {}
payload = {
"session": _session(args, ctx),
"op": op,
"params": params,
"wait": bool(args.get("wait", True)),
"timeout": float(args.get("timeout") or 60),
}
try:
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
except Exception as exc:
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
ok = data.get("ok")
result = data.get("result")
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
return f"run_editor_command: {op}", {
"output": summary[:4000],
"result": result,
"screenshot": data.get("screenshot"),
"ok": ok,
"exit_code": 0 if ok is not False else 1,
}
class GetEditorScreenshotTool:
"""Capture the current editor canvas as a data URL."""
async def execute(self, content: Any, ctx: Dict[str, Any]):
args = _parse(content)
payload = {
"session": _session(args, ctx),
"op": "screenshot",
"params": {},
"wait": True,
"timeout": float(args.get("timeout") or 30),
}
try:
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"), timeout=45)
except Exception as exc:
return "get_editor_screenshot: error", {"error": f"get_editor_screenshot failed: {exc}", "exit_code": 1}
result = data.get("result") if isinstance(data.get("result"), dict) else {}
shot = data.get("screenshot") or result.get("dataUrl")
if not shot:
return "get_editor_screenshot: unavailable", {
"error": "No editor screenshot was returned (is the editor open?).",
"exit_code": 1,
}
return "get_editor_screenshot", {"output": "Captured the editor canvas.", "screenshot": shot, "exit_code": 0}
class AiEditImageTool:
"""Apply an AI image transform (remove-bg/upscale/denoise/enhance/inpaint)."""
async def execute(self, content: Any, ctx: Dict[str, Any]):
args = _parse(content)
_t = {"start": time.perf_counter()}
op = str(args.get("op") or "").strip().lower()
if op not in _AI_OPS:
return "ai_edit_image: invalid", {
"error": f"ai_edit_image `op` must be one of {sorted(_AI_OPS)}.",
"exit_code": 1,
}
owner = ctx.get("owner")
if op in ("inpaint", "generative") and not _provider_available(owner):
return "ai_edit_image: provider required", {
"error": _provider_message(),
"failure_kind": "image_provider_unavailable",
"needs_provider": True,
"exit_code": 1,
}
session = _session(args, ctx)
image = _as_data_url(args.get("image"))
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
resp.raise_for_status()
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
except Exception:
image = None
used_canvas = False
if not image:
shot = await GetEditorScreenshotTool().execute(json.dumps({"session": session}), ctx)
image = (shot[1] or {}).get("screenshot") if isinstance(shot, tuple) else None
used_canvas = bool(image)
if not image:
return "ai_edit_image: no image", {
"error": "ai_edit_image needs an `image` (data URL or same-origin URL) or an open editor.",
"exit_code": 1,
}
_t["image"] = time.perf_counter()
payload: Dict[str, Any] = {"image": image}
model_choice = str(args.get("model") or "").strip()
mask_used = False
place_mode: Optional[str] = None
if op == "upscale":
payload["scale"] = int(args.get("scale") or 2)
elif op == "denoise":
payload["strength"] = float(args.get("strength") or 0.5)
elif op in ("inpaint", "generative"):
payload["prompt"] = str(args.get("prompt") or "").strip()
if model_choice:
# inpaint_proxy reads `_model` to route to a specific model.
payload["_model"] = model_choice
mode = str(args.get("mode") or "instruction").strip().lower()
_steps = int(args.get("steps") or 0)
if _steps > 0:
payload["steps"] = _steps
mask_used = False
mask = None
if mode == "masked":
mask = _as_data_url(args.get("mask"))
if not mask:
mask = await _fetch_selection_mask(owner, session)
# An empty/tiny SAM mask on small figures is useless: drop it.
if mask and _mask_white_fraction(mask) < _MIN_MASK_FRACTION:
mask = None
if not mask:
# Fall back to the grounding detector (OWL-ViT): a dilated
# box mask around the matching object.
box = await _ground_box(owner, image, _mask_label(args, payload["prompt"]))
if box:
candidate = _rect_mask_data_url(image, box, pad_frac=0.15)
if candidate and _mask_white_fraction(candidate) >= _MIN_MASK_FRACTION:
mask = candidate
if mask:
payload["mask"] = mask
payload["mode"] = "masked"
mask_used = True
else:
# Nothing usable to mask -> whole-image instruction fallback
# rather than placing a transparent/empty layer.
mode = "instruction"
if mode != "masked":
# Instruction mode: whole-image edit with no tight mask. Send a
# full-white mask so the edit endpoint accepts the request and
# regenerates the whole frame under the instruction prompt, which
# names what must stay unchanged. This avoids the mask-compositing
# halos that loose selections caused.
payload["mode"] = "instruction"
payload.pop("mask", None)
payload["prompt"] = _ensure_keep_clause(payload["prompt"])
try:
from PIL import Image as _PILImage
_raw = _data_url_bytes(image)
_w, _h = _PILImage.open(io.BytesIO(_raw)).size
_mbuf = io.BytesIO()
_PILImage.new("L", (_w, _h), 255).save(_mbuf, "PNG")
payload["mask"] = "data:image/png;base64," + base64.b64encode(_mbuf.getvalue()).decode()
except Exception:
pass
place_mode = "masked" if mask_used else "instruction"
_t["prep"] = time.perf_counter()
try:
data = await _post(_AI_OPS[op], payload, owner)
except Exception as exc:
logger.info(
"image-op ai_edit_image op=%s stage=provider FAILED image_ms=%.1f prep_ms=%.1f total_ms=%.1f err=%s",
op, (_t["image"] - _t["start"]) * 1000.0, (_t["prep"] - _t["image"]) * 1000.0,
(time.perf_counter() - _t["start"]) * 1000.0, exc,
)
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) failed: {exc}", "exit_code": 1}
_t["provider"] = time.perf_counter()
result = data.get("image") or data.get("data")
data_url = _as_data_url(result)
if not data_url:
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) returned no image.", "exit_code": 1}
if _image_is_blank(data_url):
return f"ai_edit_image: {op}", {
"error": f"ai_edit_image ({op}) produced an empty layer.",
"failure_kind": "empty_result",
"exit_code": 1,
}
info: Dict[str, Any] = {
"output": f"Applied AI edit '{op}'" + (" to the current canvas." if used_canvas else "."),
"op": op,
"model": data.get("model") or model_choice or "",
"exit_code": 0,
}
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
except Exception as exc:
logger.warning("ai_edit_image persist failed: %s", exc)
_t["persist"] = time.perf_counter()
if bool(args.get("place", True)):
placed = await _place_layer(
data_url,
f"AI {op}",
owner,
session,
masked=mask_used,
blend=str(args.get("blend") or "normal"),
opacity=float(args.get("opacity") or 100),
mode=place_mode,
)
info["placed"] = placed
if placed:
info["output"] += " Placed the result as a new layer."
_t["place"] = time.perf_counter()
logger.info(
"image-op ai_edit_image op=%s mode=%s canvas=%s image_ms=%.1f mask_ms=%.1f "
"model_ms=%.1f persist_ms=%.1f place_ms=%.1f total_ms=%.1f",
op, place_mode or "instruction", used_canvas,
(_t["image"] - _t["start"]) * 1000.0,
(_t["prep"] - _t["image"]) * 1000.0,
(_t["provider"] - _t["prep"]) * 1000.0,
(_t["persist"] - _t["provider"]) * 1000.0,
(_t["place"] - _t["persist"]) * 1000.0,
(_t["place"] - _t["start"]) * 1000.0,
)
return f"ai_edit_image: {op}", info
class GenerateImageLayerTool:
"""Generate an image with the configured image model and place it as a layer."""
async def execute(self, content: str, ctx: dict):
args = _parse(content)
prompt = str(args.get("prompt") or "").strip()
if not prompt:
return "generate_image: invalid", {"error": "generate_image needs a non-empty `prompt`.", "exit_code": 1}
owner = ctx.get("owner")
if not _provider_available(owner):
return "generate_image: provider required", {
"error": _provider_message(),
"failure_kind": "image_provider_unavailable",
"needs_provider": True,
"exit_code": 1,
}
session = _session(args, ctx)
payload: Dict[str, Any] = {"prompt": prompt}
for key in ("model", "size", "quality"):
if args.get(key):
payload[key] = args[key]
try:
from src.ai_interaction import do_generate_image
data = await do_generate_image(
json.dumps(payload, ensure_ascii=False),
session_id=session,
owner=owner,
incognito=True,
)
except Exception as exc:
return "generate_image: error", {"error": f"generate_image failed: {exc}", "exit_code": 1}
result = data.get("image_url") or data.get("image") or data.get("data")
data_url = _as_data_url(result)
if not data_url:
return "generate_image", {"error": "generate_image returned no image.", "exit_code": 1}
info: Dict[str, Any] = {
"output": f"Generated an image for: {prompt[:120]}",
"image_size": data.get("image_size"),
"model": data.get("image_model") or str(args.get("model") or ""),
"exit_code": 0,
}
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
except Exception as exc:
logger.warning("generate_image persist failed: %s", exc)
if bool(args.get("place", True)):
placed = await _place_layer(
data_url,
str(args.get("name") or "Generated image"),
owner,
session,
masked=bool(args.get("mask")),
blend=str(args.get("blend") or "normal"),
opacity=float(args.get("opacity") or 100),
)
info["placed"] = placed
if placed:
info["output"] += " Placed it as a new layer."
return "generate_image", info
+573 -115
View File
@@ -3,8 +3,9 @@ import json
import os
import re
import difflib
import fnmatch
import secrets
import shutil
import time
import tempfile
from typing import Optional, Dict, Any, Tuple, List
@@ -18,6 +19,8 @@ _CODENAV_SKIP_DIRS = frozenset({
})
_CODENAV_MAX_HITS = 200
_CODENAV_MAX_LINE = 400
_GREP_TIMEOUT_SECONDS = 20
_GREP_STDERR_PREFIX = 20_000
_STRUCTURED_DOCUMENT_SUFFIXES = frozenset({
".doc", ".docx", ".epub", ".pdf", ".pptx", ".xls", ".xlsx",
})
@@ -88,6 +91,79 @@ def _glob_to_regex(pat: str) -> "re.Pattern":
i += 1
return re.compile("".join(out))
def _validate_grep_descriptor(descriptor):
"""Revalidate an inert parent snapshot without inherited ContextVars."""
path, identity, ancestors = descriptor
if os.path.islink(path) or os.path.realpath(path) != path:
raise ValueError("grep: resource path changed")
for parent, observed in ancestors:
info = os.stat(parent, follow_symlinks=False)
if not os.path.isdir(parent) or (info.st_dev, info.st_ino) != tuple(observed):
raise ValueError("grep: resource ancestor changed")
info = os.stat(path, follow_symlinks=False)
import stat
if (not stat.S_ISREG(info.st_mode) or info.st_nlink > 1
or (info.st_dev, info.st_ino) != tuple(identity)):
raise ValueError("grep: resource identity changed")
def _python_grep_worker(payload: dict, output_queue) -> None:
"""Spawn-safe fallback grep worker used when ripgrep is unavailable.
Keep this at module scope: a frozen Windows executable cannot safely be
relaunched as ``sys.executable -c ...``, while multiprocessing can invoke a
top-level target through its frozen-process bootstrap.
"""
try:
flags = re.IGNORECASE if payload["ignore_case"] else 0
try:
regex = re.compile(payload["pattern"], flags)
glob_regex = (
_glob_to_regex(payload["glob"].replace("\\", "/"))
if payload["glob"]
else None
)
except re.error as exc:
output_queue.put(("error", f"grep: bad pattern: {exc}"))
return
max_hits = payload["max_hits"]
hits = 0
for descriptor in payload["files"]:
if hits >= max_hits:
break
path, identity, ancestors = descriptor
_validate_grep_descriptor(descriptor)
relative = os.path.relpath(path, payload["base"]).replace(os.sep, "/")
if glob_regex and not (
glob_regex.fullmatch(relative) or glob_regex.fullmatch(os.path.basename(path))
):
continue
try:
with open(path, "r", encoding="utf-8", errors="strict") as handle:
info = os.fstat(handle.fileno())
if (info.st_dev, info.st_ino) != tuple(identity) or info.st_nlink > 1:
raise ValueError("grep: resource identity changed before read")
for number, line in enumerate(handle, 1):
if regex.search(line):
output_queue.put(("match", path, number, line.rstrip()[:_CODENAV_MAX_LINE]))
hits += 1
if hits >= max_hits:
break
except UnicodeDecodeError:
continue
except OSError as error:
output_queue.put(("error", f"grep: {error}"))
return
output_queue.put(("done",))
except BaseException as exc:
try:
output_queue.put(("error", f"grep: fallback worker failed: {exc}"))
except BaseException:
pass
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
if old == new:
return None
@@ -268,32 +344,82 @@ class ReadFileTool:
data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]"
return {"output": data, "exit_code": 0}
def _write_new_file_without_overwrite(path: str, body: str) -> None:
"""Publish a new file without exposing a writable placeholder at its path.
Stage beside the destination, then hard-link it into place. The link is
atomic and fails if another writer created the destination first.
"""
directory = os.path.dirname(path) or "."
temporary_path = os.path.join(
directory, f".odysseus-write-{secrets.token_hex(16)}.tmp"
)
fd = os.open(
temporary_path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o666,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as temporary_file:
fd = None
temporary_file.write(body)
os.link(temporary_path, path)
finally:
if fd is not None:
os.close(fd)
try:
os.unlink(temporary_path)
except FileNotFoundError:
pass
class _EmptyBodyWouldTruncate(Exception):
"""Raised inside the write thread when an undeclared empty body is about to
replace a file that holds bytes. Carries the size at risk so the caller can be
told what it would have lost (#6414)."""
def __init__(self, path: str, existing_bytes: int):
super().__init__(path)
self.path = path
self.existing_bytes = existing_bytes
def _parse_write_intent(content: str) -> tuple[str, str, bool, bool]:
"""Classify the original transport, before binding or source normalization.
The returned clear flag is derived here, never from a caller-supplied key.
"""
if not isinstance(content, str):
raise ValueError("write_file: expected string arguments")
if content.lstrip().startswith("{"):
try:
args = json.loads(content)
except (TypeError, ValueError) as error:
raise ValueError("write_file: expected valid JSON arguments") from error
if not isinstance(args, dict):
raise ValueError("write_file: expected a JSON object")
path, body = args.get("path"), args.get("content")
if not isinstance(body, str):
raise ValueError("write_file: content required and must be a string")
if not isinstance(path, str):
raise ValueError("write_file: path required and must be a string")
return path.strip(), body, True, not body.strip()
path, delimiter, body = content.partition("\n")
return path.strip(), body, bool(delimiter), False
class WriteFileTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import _display_tool_path, _resolve_tool_path, _resolve_search_root, _truncate
lines = content.split("\n", 1)
raw_path = lines[0].strip()
body = lines[1] if len(lines) > 1 else ""
# Decode JSON-object args (the fenced inline-args shape
# ```write_file {"path": "...", "content": "..."}```), matching
# ReadFileTool above. Without this the whole JSON string becomes the
# path and the file is written under a garbage name. This is the live
# path: there is no filesystem MCP server, so write_file always runs
# here via _direct_fallback, not through _build_mcp_args.
_stripped = content.strip()
if _stripped.startswith("{"):
try:
_a = json.loads(_stripped)
if not isinstance(_a, dict):
return {"error": "write_file: expected a JSON object", "exit_code": 1}
raw_path_value = _a.get("path")
body_value = _a.get("content")
raw_path = raw_path_value.strip() if isinstance(raw_path_value, str) else ""
if not isinstance(body_value, str):
return {"error": "write_file: content required", "exit_code": 1}
body = body_value
except (json.JSONDecodeError, TypeError, ValueError):
return {"error": "write_file: expected valid JSON arguments", "exit_code": 1}
from src.tool_execution import _display_tool_path, _resolve_tool_path
from src.agent_runtime.resource_binding import active_resource_operation
bound = active_resource_operation()
original = bound.operation.input if bound is not None else content
try:
_, _, has_section, declared_clear = _parse_write_intent(original)
raw_path, body, _, _ = _parse_write_intent(content)
except ValueError as error:
return {"error": str(error), "exit_code": 1}
if not raw_path:
return {"error": "write_file: path required", "exit_code": 1}
try:
@@ -323,16 +449,6 @@ class WriteFileTool:
"exit_code": 1,
"artifact_format_error": True,
}
if not body:
return {
"error": (
f"write_file: {path}: content required; refusing to create an "
"empty file. Call write_file again with the exact filename and "
"non-empty content. If you need a directory, create it from "
"bash/python instead."
),
"exit_code": 1,
}
# write_file is a UTF-8 text writer. Refuse to silently destroy an
# existing PDF, image, archive, or media artifact produced by a
# format-aware tool, especially after the agent has verified it.
@@ -347,6 +463,10 @@ class WriteFileTool:
"exit_code": 1,
"binary_artifact_preserved": target_existed,
}
if not has_section:
return {"error": "write_file: content required; missing content section", "exit_code": 1}
if raw_path.endswith(("/", "\\")) or os.path.isdir(path):
return {"error": "write_file: target is a directory", "exit_code": 1}
# This writer truncates in place. Once that stage is reached, a failure
# may leave a partial file; report it so effect evidence stays honest.
attempted = []
@@ -361,21 +481,67 @@ class WriteFileTool:
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
if not body.strip() and not declared_clear:
# Empty/whitespace-only writes to an already-empty file are no-ops.
# Avoid reopening in truncating mode: another writer may have added
# data since the read above.
if os.path.isfile(path):
existing_bytes = os.path.getsize(path)
if existing_bytes > 0:
raise _EmptyBodyWouldTruncate(path, existing_bytes)
return old, 0
try:
if body:
# Publish whitespace content atomically. Writing it
# after exclusive creation could overwrite bytes from
# a writer that filled the new placeholder meanwhile.
attempted.append(True)
_write_new_file_without_overwrite(path, body)
else:
# An exact empty body needs no staged data, so create
# the file exclusively and never write through it.
attempted.append(True)
with open(path, "x", encoding="utf-8"):
pass
except FileExistsError:
if os.path.isfile(path):
existing_bytes = os.path.getsize(path)
if existing_bytes > 0:
raise _EmptyBodyWouldTruncate(path, existing_bytes)
return old, 0
raise
return old, len(body)
attempted.append(True)
with open(path, "w", encoding="utf-8") as f:
f.write(body)
return old, len(body)
old_content, size = await asyncio.to_thread(_write)
except _EmptyBodyWouldTruncate as e:
clear_call = json.dumps({"path": raw_path, "content": ""})
return {
"error": (
f"write_file: refused to write an empty body over {e.path} — it holds "
f"{e.existing_bytes} bytes, which the write would have destroyed, so "
f"the file is unchanged. To clear it on purpose, resend with an "
f"explicit empty content: {clear_call}"
),
"exit_code": 1,
}
except PermissionError:
return {"error": f"write_file: {path}: permission denied", "exit_code": 1,
**({"mutation_attempted": True} if attempted else {})}
except OSError as e:
return {"error": f"write_file: {path}: {e}", "exit_code": 1,
**({"mutation_attempted": True} if attempted else {})}
diff = _unified_diff(old_content, body, path)
committed_body = old_content if size == 0 and not declared_clear and not body.strip() else body
diff = _unified_diff(old_content, committed_body, path)
result = {
"output": f"Wrote {size} bytes to {_display_tool_path(path)}",
"output": (f"Wrote {size} bytes to {_display_tool_path(path)}" if attempted else
f"No write performed for {_display_tool_path(path)} (implicit empty body)"),
"exit_code": 0,
**({"write_noop": True} if not attempted else {}),
}
if diff:
result["diff"] = diff
@@ -619,7 +785,7 @@ def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str
class LsTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import _display_tool_path, _resolve_search_root, _truncate
from src.tool_execution import _display_tool_path, _is_denied_tool_path, _resolve_search_root, _truncate
raw_path = ""
_s = (content or "").strip()
if _s.startswith("{"):
@@ -635,15 +801,18 @@ class LsTool:
return {"error": f"ls: {e}", "exit_code": 1}
def _ls():
from src.agent_runtime.resources import _control_plane_snapshot
if not os.path.isdir(root):
return None, f"ls: {root}: not a directory"
rows = []
snapshot = _control_plane_snapshot()
try:
with os.scandir(root) as it:
for entry in it:
if entry.name.startswith("."):
continue
if not _visible_bound_resource(entry.path):
if (_is_denied_tool_path(os.path.realpath(entry.path), snapshot=snapshot)
or not _visible_bound_resource(entry.path)):
continue
try:
is_dir = entry.is_dir(follow_symlinks=False)
@@ -672,6 +841,8 @@ class GlobTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import (
_SENSITIVE_BASENAMES,
_can_traverse_tool_path,
_is_denied_tool_path,
_display_tool_path,
_is_sensitive_path,
_resolve_tool_path,
@@ -696,10 +867,12 @@ class GlobTool:
return {"error": f"glob: {e}", "exit_code": 1}
def _glob():
from src.agent_runtime.resources import _control_plane_snapshot
base = os.path.abspath(root)
if not os.path.isdir(base):
return None, f"glob: {root}: not a directory"
rbase = os.path.realpath(base)
snapshot = _control_plane_snapshot()
norm_pat = pattern.replace("\\", "/")
# Fast path: literal pattern (no wildcards) → direct path lookup.
if not any(c in norm_pat for c in "*?["):
@@ -716,7 +889,8 @@ class GlobTool:
# .ssh/id_rsa, …) falls through to the walk, which skips it —
# otherwise glob would surface secret paths that read_file /
# grep already refuse to touch.
if inside and os.path.exists(cand) and not _is_sensitive_path(cand) and _visible_bound_resource(cand):
if (inside and os.path.exists(cand) and not _is_denied_tool_path(cand, snapshot=snapshot)
and _visible_bound_resource(cand)):
return [cand], None
# Literal not at exact path — fall through to walk so
# e.g. "foo.py" still matches at any depth (like rglob).
@@ -726,13 +900,18 @@ class GlobTool:
cap = _CODENAV_MAX_HITS * 5
try:
for dp, dns, fns in os.walk(base):
if not _can_traverse_tool_path(os.path.realpath(dp), snapshot=snapshot):
dns[:] = []
continue
# Prune skipped dirs before descending (unlike rglob which
# descends first then filters — fatal on large node_modules).
# Sensitive dirs (.ssh, .gnupg, …) are pruned too so glob
# never enumerates the keys/tokens inside them.
dns[:] = [
d for d in dns
if d not in _CODENAV_SKIP_DIRS and d not in _SENSITIVE_BASENAMES
if d not in _CODENAV_SKIP_DIRS
and d not in _SENSITIVE_BASENAMES
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)), snapshot=snapshot)
]
for name in fns + dns:
full = os.path.join(dp, name)
@@ -740,7 +919,8 @@ class GlobTool:
if regex.fullmatch(rel) or regex.fullmatch(name):
# Skip deny-listed sensitive files (.env, id_rsa,
# known_hosts, …) the same way grep does.
if _is_sensitive_path(os.path.realpath(full)) or not _visible_bound_resource(full):
if (_is_denied_tool_path(os.path.realpath(full), snapshot=snapshot)
or not _visible_bound_resource(full)):
continue
try:
mtime = os.stat(full).st_mtime
@@ -767,10 +947,14 @@ class GlobTool:
class GrepTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import (
_SENSITIVE_BASENAMES,
_SENSITIVE_FILE_PATTERNS,
_agent_readable_data_subdirs,
_is_denied_tool_path,
_display_tool_path,
_can_traverse_tool_path,
_is_sensitive_path,
_resolve_tool_path,
_path_within,
_resolve_search_root,
_truncate,
)
@@ -799,83 +983,357 @@ class GrepTool:
return {"error": f"grep: {e}", "exit_code": 1}
def _grep():
import re as _re
import shutil
import multiprocessing
import queue
import subprocess
import threading
from src.agent_runtime.resources import _control_plane_snapshot
from src.constants import DATA_DIR
rg = shutil.which("rg")
real_root = os.path.realpath(root)
deadline = time.monotonic() + _GREP_TIMEOUT_SECONDS
base = real_root if os.path.isdir(real_root) else os.path.dirname(real_root)
from src.agent_runtime.resource_binding import active_resource_operation
if not os.path.exists(root):
return None, f"grep: search target not found: {_display_tool_path(root)}"
# The pathname-only fast path scans before individual resources can
# be checked. Bound searches must validate every file before read.
rg = None if active_resource_operation() is not None else shutil.which("rg")
if rg:
cmd = [rg, "--line-number", "--with-filename", "--no-heading", "--color=never",
"--max-count", str(max_hits)]
if ignore_case:
cmd.append("--ignore-case")
if glob_pat:
cmd += ["--glob", glob_pat]
# --iglob (not --glob) so the exclusion is case-insensitive:
# on a case-insensitive filesystem "ID_RSA"/"Known_Hosts"
# resolve to the same secret as their lowercase forms, and the
# Python fallback below already folds case via _is_sensitive_path.
for _pat in _SENSITIVE_FILE_PATTERNS:
cmd += ["--iglob", f"!*{_pat}*"]
for _d in _CODENAV_SKIP_DIRS:
cmd += ["--glob", f"!**/{_d}/**"]
cmd += ["--regexp", pattern, root]
try:
import subprocess
p = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
# ripgrep: 0 = matches, 1 = no matches, 2 = failed scan.
# Do not present invalid patterns or IO failures as absence.
if p.returncode not in (0, 1):
detail = (p.stderr or '').strip()[:1200]
return None, f"grep: search failed (exit {p.returncode}): {detail or 'no diagnostic available'}"
lines = [ln for ln in (p.stdout or "").splitlines() if ln][:max_hits]
return lines, None
except subprocess.TimeoutExpired:
return None, "grep: timed out"
except Exception as _e:
return None, f"grep: {_e}"
bound = active_resource_operation()
if bound is not None:
bound.validate()
files = []
snapshot = _control_plane_snapshot()
def check_deadline():
if time.monotonic() >= deadline:
raise TimeoutError("grep: timed out")
def observe_file(path):
check_deadline()
if os.path.islink(path):
return
canonical = os.path.realpath(path)
if not _path_within(canonical, base) or _is_denied_tool_path(canonical, snapshot=snapshot):
return
if bound is not None:
try:
bound.resolve_path(canonical)
except ValueError:
# Intentionally denied publication control-plane paths
# are omitted before any producer is allowed to read.
return
info = os.stat(canonical, follow_symlinks=False)
if not os.path.isfile(canonical):
return
ancestors = []
parent = os.path.dirname(canonical)
while _path_within(parent, base):
observed = os.stat(parent, follow_symlinks=False)
ancestors.append((parent, (observed.st_dev, observed.st_ino)))
if parent == base:
break
parent = os.path.dirname(parent)
files.append((canonical, (info.st_dev, info.st_ino), tuple(ancestors)))
if len(files) > 100_000:
raise ValueError("grep: enumeration limit exceeded; scan incomplete")
try:
rx = _re.compile(pattern, _re.IGNORECASE if ignore_case else 0)
except _re.error as _e:
return None, f"grep: bad pattern: {_e}"
hits = []
scan_errors = []
if os.path.isfile(root):
file_iter = [root]
else:
file_iter = []
for dp, dns, fns in os.walk(root, onerror=scan_errors.append):
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
for fn in fns:
if glob_pat and not fnmatch.fnmatch(fn, glob_pat):
if os.path.islink(root):
raise ValueError("grep: symlink search root is not allowed")
if os.path.isfile(real_root):
observe_file(real_root)
elif os.path.isdir(real_root):
pending_directories = [real_root]
enumerated = 0
while pending_directories:
check_deadline()
directory = pending_directories.pop()
if not _can_traverse_tool_path(directory, snapshot=snapshot):
continue
file_iter.append(os.path.join(dp, fn))
for fp in file_iter:
if len(hits) >= max_hits:
break
with os.scandir(directory) as entries:
for entry in entries:
check_deadline()
enumerated += 1
if enumerated > 100_000:
raise ValueError("grep: enumeration limit exceeded; scan incomplete")
if entry.is_symlink():
continue
canonical = os.path.realpath(entry.path)
if not _path_within(canonical, base):
raise ValueError("grep: directory identity changed during enumeration")
if entry.is_dir(follow_symlinks=False):
if (entry.name not in _CODENAV_SKIP_DIRS
and _can_traverse_tool_path(canonical, snapshot=snapshot)
and (bound is None or _is_denied_tool_path(canonical, snapshot=snapshot)
or _visible_bound_resource(canonical))):
pending_directories.append(canonical)
else:
observe_file(entry.path)
else:
raise FileNotFoundError(f"grep: {root}: not found")
check_deadline()
except (OSError, ValueError) as error:
return None, str(error) if str(error).startswith("grep:") else f"grep: {error}"
descriptors = {record[0]: record for record in files}
targets = list(descriptors)
lines: list[str] = []
def parse_rg_result(raw: str) -> Optional[str]:
try:
resolved_file = _resolve_tool_path(fp)
except ValueError:
# Apply the same workspace/sensitive-path checks to each
# discovered file, not just the initial search directory.
continue
record = json.loads(raw)
except (TypeError, json.JSONDecodeError):
return None
if record.get("type") != "match":
return None
data = record.get("data") or {}
path = (data.get("path") or {}).get("text")
text_value = (data.get("lines") or {}).get("text")
number = data.get("line_number")
if not isinstance(path, str) or not isinstance(text_value, str):
return None
absolute = path if os.path.isabs(path) else os.path.join(base, path)
canonical = os.path.realpath(absolute)
if canonical not in descriptors:
raise ValueError("grep: producer returned an undeclared resource")
_validate_grep_descriptor(descriptors[canonical])
if bound is not None:
bound.resolve_path(canonical)
return f"{_display_tool_path(canonical)}:{number}:{text_value.rstrip()[:_CODENAV_MAX_LINE]}"
def run_rg(cmd: list[str]) -> Optional[str]:
try:
with open(resolved_file, "r", encoding="utf-8", errors="strict") as f:
for i, line in enumerate(f, 1):
if rx.search(line):
hits.append(f"{fp}:{i}:{line.rstrip()[:_CODENAV_MAX_LINE]}")
if len(hits) >= max_hits:
break
except UnicodeDecodeError:
continue
except OSError as error:
scan_errors.append(error)
if scan_errors:
return None, "grep: search incomplete; one or more files or directories could not be read"
return hits, None
process = subprocess.Popen(
cmd,
cwd=base,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
except Exception as exc:
return f"grep: {exc}"
output: queue.Queue[Optional[str]] = queue.Queue(maxsize=max_hits + 2)
stderr_prefix: list[str] = []
stderr_size = 0
stop_reader = threading.Event()
def enqueue_stdout(value: Optional[str]) -> bool:
# The consumer stops at the result cap or deadline. Never
# leave a producer blocked on its bounded queue afterward.
while not stop_reader.is_set():
try:
output.put(value, timeout=0.05)
return True
except queue.Full:
continue
return False
def read_stdout() -> None:
assert process.stdout is not None
try:
for line in process.stdout:
if not enqueue_stdout(line.rstrip("\n")):
break
finally:
enqueue_stdout(None)
def read_stderr() -> None:
nonlocal stderr_size
assert process.stderr is not None
while True:
chunk = process.stderr.read(4096)
if not chunk:
break
if stderr_size < _GREP_STDERR_PREFIX:
kept = chunk[:_GREP_STDERR_PREFIX - stderr_size]
stderr_prefix.append(kept)
stderr_size += len(kept)
stdout_thread = threading.Thread(target=read_stdout, daemon=True)
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
stdout_thread.start()
stderr_thread.start()
timed_out = False
capped = False
try:
while len(lines) < max_hits:
remaining = deadline - time.monotonic()
if remaining <= 0:
timed_out = True
break
try:
raw = output.get(timeout=remaining)
except queue.Empty:
timed_out = True
break
if raw is None:
break
parsed = parse_rg_result(raw)
if parsed and parsed not in lines:
lines.append(parsed)
capped = len(lines) >= max_hits
finally:
stop_reader.set()
if (timed_out or capped) and process.poll() is None:
process.terminate()
try:
remaining = max(0.01, deadline - time.monotonic())
return_code = process.wait(timeout=min(1, remaining))
except subprocess.TimeoutExpired:
process.kill()
return_code = process.wait()
stdout_thread.join()
stderr_thread.join()
if timed_out:
return "grep: timed out"
if not capped and return_code not in (0, 1):
detail = "".join(stderr_prefix).strip()
return f"grep: {detail or f'process exited {return_code}'}"
return None
if rg:
if glob_pat:
try:
glob_regex = _glob_to_regex(glob_pat.replace("\\", "/"))
targets = [
target for target in targets
if glob_regex.fullmatch(os.path.relpath(target, base).replace(os.sep, "/"))
or glob_regex.fullmatch(os.path.basename(target))
]
except re.error:
pass
# Validate even when policy filtering leaves no search targets.
if not targets:
cmd_probe = [rg, "--json", "--no-config"]
if glob_pat:
cmd_probe += ["--glob", glob_pat]
cmd_probe += ["--regexp", pattern, "--", "-"]
error = run_rg(cmd_probe)
return (None, error) if error else ([], None)
relative_targets = [os.path.relpath(target, base) for target in targets]
for offset in range(0, len(relative_targets), 128):
if len(lines) >= max_hits:
break
cmd = [
rg, "--json", "--no-config", "--no-follow",
"--max-count", str(max_hits - len(lines)),
"--max-columns", str(_CODENAV_MAX_LINE),
"--max-columns-preview",
]
if ignore_case:
cmd.append("--ignore-case")
if glob_pat:
cmd += ["--glob", glob_pat]
for sensitive_pattern in _SENSITIVE_FILE_PATTERNS:
cmd += ["--iglob", f"!{sensitive_pattern}"]
for skipped_dir in _CODENAV_SKIP_DIRS:
cmd += ["--glob", f"!**/{skipped_dir}/**"]
cmd += ["--regexp", pattern, "--", *relative_targets[offset:offset + 128]]
try:
for path in targets[offset:offset + 128]:
check_deadline()
_validate_grep_descriptor(descriptors[path])
if bound is not None:
bound.resolve_path(path)
error = run_rg(cmd)
except (OSError, ValueError) as exc:
return None, f"grep: {exc}"
if error:
return None, error
return lines, None
# This runs inside asyncio.to_thread(), so forking would clone a
# multithreaded process and can deadlock. Spawn is platform-safe and
# PyInstaller-compatible via launcher's early freeze_support().
payload = {
"root": base,
"base": base,
"files": tuple(files),
"pattern": pattern,
"ignore_case": ignore_case,
"glob": glob_pat,
"max_hits": max_hits,
"skip_dirs": tuple(_CODENAV_SKIP_DIRS),
"sensitive_names": tuple(
set(_SENSITIVE_BASENAMES) | set(_SENSITIVE_FILE_PATTERNS)
),
}
try:
context = multiprocessing.get_context("spawn")
output_queue = context.Queue(maxsize=max_hits + 2)
worker = context.Process(
target=_python_grep_worker, args=(payload, output_queue)
)
worker.start()
except Exception as exc:
try:
output_queue.close()
except (NameError, OSError, ValueError):
pass
return None, f"grep: could not start fallback worker: {exc}"
error = None
completed = False
try:
while len(lines) < max_hits:
remaining = deadline - time.monotonic()
if remaining <= 0:
error = "grep: timed out"
break
try:
# Keep queue waits short enough to observe a spawn
# worker that dies during bootstrap/import before it
# can enqueue either an error or the done sentinel.
record = output_queue.get(timeout=min(0.05, remaining))
except queue.Empty:
if worker.is_alive():
continue
worker.join(timeout=0)
try:
# A multiprocessing queue's feeder can make the
# final record visible at process-exit time. Give
# that record precedence over the exit status.
remaining = deadline - time.monotonic()
record = output_queue.get(
timeout=min(0.05, max(0, remaining))
)
except queue.Empty:
error = f"grep: fallback worker exited {worker.exitcode}"
break
if record[0] == "done":
completed = True
break
if record[0] == "error":
error = record[1]
break
_, path, number, text_value = record
canonical = os.path.realpath(path)
if canonical not in descriptors:
error = "grep: fallback returned an undeclared resource"
break
try:
_validate_grep_descriptor(descriptors[canonical])
if bound is not None:
bound.resolve_path(canonical)
except (OSError, ValueError) as exc:
error = f"grep: {exc}"
break
rendered = f"{_display_tool_path(canonical)}:{number}:{text_value}"
if rendered not in lines:
lines.append(rendered)
finally:
if completed:
worker.join(timeout=min(1, max(0.01, deadline - time.monotonic())))
if worker.is_alive():
worker.terminate()
worker.join(timeout=1)
if worker.is_alive():
worker.kill()
worker.join()
output_queue.close()
if error:
return None, error
if (not completed or worker.exitcode not in (0, None)) and len(lines) < max_hits:
return None, f"grep: fallback worker exited {worker.exitcode}"
return lines, None
lines, err = await asyncio.to_thread(_grep)
if err:
+16 -7
View File
@@ -11,7 +11,7 @@ import sys
import time
import json
from typing import Optional
from urllib.parse import urlparse
from urllib.parse import urlparse, urlsplit, urlunsplit
import httpx
@@ -76,12 +76,14 @@ def _resolve_fontfile_for_text(text: str) -> str:
async def _cancel_host_shell_bridge_request(
url: str, token: str, request_id: str,
) -> None:
base = url.rsplit("/", 1)[0]
if not token or not is_host_shell_bridge_url_allowed(url):
return
target = host_shell_bridge_endpoint_url(url, "/cancel")
try:
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
async with httpx.AsyncClient(timeout=timeout) as client:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
await client.post(
f"{base}/cancel",
target,
json={"request_id": request_id},
headers={"X-Odysseus-TUI-Bridge-Token": token},
)
@@ -216,6 +218,12 @@ def is_host_shell_bridge_url_allowed(url: str) -> bool:
return True
def host_shell_bridge_endpoint_url(url: str, path: str) -> str:
"""Replace a validated bridge URL's path without changing its authority."""
parsed = urlsplit(url)
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
def _replace_workspace_alias(content: str, cwd: str) -> str:
"""Map virtual /workspace paths without corrupting absolute host paths."""
return re.sub(
@@ -682,6 +690,7 @@ class HostShellTool:
return {"error": "host_shell: invalid bridge URL", "exit_code": 1}
if not token:
return {"error": "host_shell: bridge token missing", "exit_code": 1}
run_url = host_shell_bridge_endpoint_url(url, "/run")
job_id = str(args.get("job_id") or "").strip()
if not command and not job_id:
@@ -716,9 +725,9 @@ class HostShellTool:
request_body["request_id"] = request_id
try:
async with httpx.AsyncClient(timeout=timeout + 5) as client:
async with httpx.AsyncClient(timeout=timeout + 5, trust_env=False) as client:
resp = await client.post(
url,
run_url,
json=request_body,
headers={"X-Odysseus-TUI-Bridge-Token": token},
)
@@ -746,7 +755,7 @@ class HostShellTool:
while time.monotonic() < deadline:
await asyncio.sleep(0.25)
poll = await client.post(
url,
run_url,
json={"job_id": auto_job_id},
headers={"X-Odysseus-TUI-Bridge-Token": token},
)
+1 -5
View File
@@ -14,7 +14,6 @@ import tempfile
import time
import html
import hashlib
import pwd
import urllib.parse
import urllib.request
import uuid
@@ -32,10 +31,7 @@ _ACTIVE_BROWSER_SESSIONS: set[str] = set()
def _service_home() -> Path:
"""Return the account home even when a task overrides ``HOME``."""
try:
return Path(pwd.getpwuid(os.getuid()).pw_dir)
except (KeyError, OSError):
return Path.home()
return platform_compat.service_home()
def _host_npm_roots() -> list[Path]:
File diff suppressed because it is too large Load Diff