feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""Domain errors shared by the reusable service layer.
|
||||
|
||||
Services are transport-agnostic: they raise :class:`ServiceError` carrying a
|
||||
stable ``code`` and an HTTP ``status`` hint. The REST layer maps it to an
|
||||
``HTTPException`` (via the global handler in ``backend.main``) while the tool
|
||||
layer maps it to a :class:`backend.tools.context.ToolError`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ServiceError(Exception):
|
||||
"""Base error for the shared business-logic services."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str = "service_error",
|
||||
status: int = 400,
|
||||
details: dict[str, Any] | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.details = details or {}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""File reading services shared by REST routes and the AI tool layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.files")
|
||||
|
||||
|
||||
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
|
||||
"""Return the raw text content of a vault file (no redaction)."""
|
||||
root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
try:
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied reading raw file {path}: {e}")
|
||||
raise ServiceError(f"Permission denied: cannot read file {path}", code="permission_denied", status=403) from e
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
raw = file_path.read_bytes().decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading binary raw file {path}: {e}")
|
||||
raise ServiceError(f"Cannot read file: {e!s}", code="read_error", status=500) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading raw file {path}: {e}")
|
||||
raise ServiceError(f"Error reading file: {e!s}", code="read_error", status=500) from e
|
||||
|
||||
return {"vault": vault_name, "path": path, "raw": raw}
|
||||
|
||||
|
||||
def read_file_text(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
*,
|
||||
redact: bool = True,
|
||||
max_bytes: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a vault file's text content, optionally redacted and size-capped.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
|
||||
error (500).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
size = target.stat().st_size
|
||||
if max_bytes is not None and size > max_bytes:
|
||||
raise ServiceError(
|
||||
f"File too large ({size} bytes > {max_bytes})",
|
||||
code="file_too_large",
|
||||
status=413,
|
||||
details={"vault": vault_name, "path": path, "size": size},
|
||||
)
|
||||
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if redact:
|
||||
from backend.secret_redactor import redact_file_content
|
||||
|
||||
content = redact_file_content(content, path)
|
||||
|
||||
return {"vault": vault_name, "path": path, "size": size, "content": content}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Vault path resolution shared by the REST routes and the AI tool layer.
|
||||
|
||||
This is the single implementation of the anti path-traversal check. Routes map
|
||||
:class:`ServiceError` to ``HTTPException`` and tools map it to
|
||||
:class:`backend.tools.context.ToolError`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
|
||||
logger = logging.getLogger("obsigate.services.paths")
|
||||
|
||||
|
||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``path_error`` (500) when the path cannot be resolved,
|
||||
``path_outside_vault`` (403) when it escapes the vault root.
|
||||
"""
|
||||
full_path = vault_root / (relative_path or "")
|
||||
try:
|
||||
resolved = full_path.resolve(strict=False)
|
||||
root = vault_root.resolve(strict=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
|
||||
raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e
|
||||
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError:
|
||||
# Case-insensitive fallback for Windows / Docker path casing.
|
||||
if not str(resolved).lower().startswith(str(root).lower()):
|
||||
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
|
||||
raise ServiceError(
|
||||
"Access denied: path outside vault",
|
||||
code="path_outside_vault",
|
||||
status=403,
|
||||
details={"path": relative_path},
|
||||
) from None
|
||||
return resolved
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Search services shared by REST routes and the AI tool layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def search_vaults(
|
||||
q: str,
|
||||
vault: str = "all",
|
||||
tag: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
No permission filtering is applied here: callers that need it (the tool
|
||||
layer) filter the ``results`` list themselves.
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
"query": q,
|
||||
"vault_filter": vault,
|
||||
"tag_filter": tag,
|
||||
"count": len(page),
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"results": page,
|
||||
}
|
||||
|
||||
|
||||
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||
"""Return tag → count, optionally restricted to a single vault."""
|
||||
from backend.search import get_all_tags
|
||||
|
||||
return get_all_tags(vault_filter=vault)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Vault listing and directory browsing services.
|
||||
|
||||
Single source of truth consumed by both the REST routes (``/api/vaults``,
|
||||
``/api/browse/{vault}``) and the AI tool layer (``list_vaults``,
|
||||
``list_directory``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
|
||||
|
||||
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the vaults *user* may access, with summary metadata."""
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.indexer import index
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, data in index.items():
|
||||
if not check_vault_access(name, user):
|
||||
continue
|
||||
result.append({
|
||||
"name": name,
|
||||
"file_count": len(data.get("files", [])),
|
||||
"tag_count": len(data.get("tags", {})),
|
||||
"type": data.get("config", {}).get("type", "VAULT"),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_vault_root(vault_name: str) -> Path:
|
||||
"""Return the filesystem root of *vault_name* or raise ``not_found``."""
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault_name)
|
||||
if not data:
|
||||
raise ServiceError(
|
||||
f"Vault '{vault_name}' not found",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name},
|
||||
)
|
||||
return Path(data["path"])
|
||||
|
||||
|
||||
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"""Return the direct children of a vault directory (directories first)."""
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
from backend.vault_settings import get_vault_setting
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path) if path else root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
raise ServiceError(
|
||||
f"Path not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
hide_hidden = (get_vault_setting(vault_name) or {}).get("hideHiddenFiles", False)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
try:
|
||||
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(root)).replace("\\", "/")
|
||||
if entry.is_dir():
|
||||
# Count only direct children (files and subdirs) for performance.
|
||||
try:
|
||||
file_count = sum(
|
||||
1 for child in entry.iterdir()
|
||||
if (not hide_hidden or not child.name.startswith("."))
|
||||
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
|
||||
or child.is_dir())
|
||||
)
|
||||
except PermissionError:
|
||||
file_count = 0
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "directory",
|
||||
"children_count": file_count,
|
||||
})
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "file",
|
||||
"size": entry.stat().st_size,
|
||||
"extension": entry.suffix.lower(),
|
||||
})
|
||||
except PermissionError:
|
||||
raise ServiceError("Permission denied", code="permission_denied", status=403) from None
|
||||
|
||||
return {"vault": vault_name, "path": path, "items": items}
|
||||
Reference in New Issue
Block a user