fix: enforce agent permissions and persist tool approvals

Validate MCP arguments and recursive operation scopes, persist approval batches, and recheck permissions for queued work.

Handle remote metadata requests independently of proxy settings and update Ant Design component APIs.
This commit is contained in:
shiyu committed 2026-10-02 21:58:04 +08:00
1 parent 38ad2f64ff
commit 65dbd407a9
46 files changed
+1568 -508

No files matched your search

+9
View File
@@ -75,6 +75,15 @@ Run asynchronous background tasks — file indexing, data backups, scheduled job
An integrated AI agent with built-in tools for VFS operations, web fetching, and file processing — bringing intelligent automation directly into your cloud storage.
#### MCP and Agent boundaries
- The MCP endpoint is `/api/mcp/`. External clients authenticate with a Foxel Bearer Token and own their confirmation UI; the server always enforces the authenticated user's path permissions.
- `time`, directory listing, metadata, text reads, search, processor listing, and `web_fetch` can run directly. `web_fetch` supports GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS, including external write requests, without an additional Foxel approval step.
- Agent writes, creates, deletes, moves, copies, renames, and `processors_run` calls create database-backed approval batches. Approval requests must include the returned `approval_batch_id`; execution uses the server-stored arguments, rejects expired or cross-user requests, and does not re-execute repeated confirmations.
- Recursive changes and directory processing validate all enumerated children before starting. Any denied or unverifiable path rejects the batch; queued work rechecks permissions and stops after a scope change.
- Tools return structured error codes such as `permission_denied`, `invalid_arguments`, `permission_scope_unverifiable`, `approval_expired`, `request_timeout`, and `execution_failed` without exposing internal exceptions or credentials.
- Agent conversations currently support OpenAI, Anthropic, and Ollama interfaces, with at most eight tool-call rounds per request. Processor task IDs can be tracked in the task queue.
### 🌐 Protocol Mappings
Access your files through familiar protocols:
+9
View File
@@ -75,6 +75,15 @@
内置 AI Agent,提供 VFS 操作、网页抓取、文件处理等工具,将智能自动化能力直接融入你的云盘。
#### MCP 与 Agent 边界
- MCP 服务地址为 `/api/mcp/`,外部客户端使用 Foxel Bearer Token;直接调用由客户端负责确认,服务端始终执行当前用户的路径权限。
- `time`、目录浏览、元信息、文本读取、搜索、处理器列表及 `web_fetch` 可直接调用。`web_fetch` 支持 GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS,包含外部写请求但不在 Foxel 内额外审批。
- Agent 对文件写入、创建、删除、移动、复制、重命名和 `processors_run` 创建数据库审批批次。审批请求必须携带返回的 `approval_batch_id`;参数以服务端保存的原始调用为准,过期或跨用户请求会拒绝,重复确认不会再次执行。
- 目录变更和目录处理在执行前检查全部可见子项,任一项缺少权限或无法完整枚举则整批拒绝。后台任务会重新检查权限,权限变化后停止后续操作。
- 工具错误使用结构化错误码,例如 `permission_denied`、`invalid_arguments`、`permission_scope_unverifiable`、`approval_expired`、`request_timeout` 和 `execution_failed`,不会回传底层异常或凭据。
- Agent 当前支持 OpenAI、Anthropic、Ollama 对话接口;单次最多 8 轮工具调用。处理器返回任务 ID 后需要在任务队列查看进度。
### 🌐 协议映射
通过熟悉的协议访问你的文件:
+4 -3
View File
@@ -6,7 +6,7 @@ from fastapi.responses import StreamingResponse
from api.response import success
from domain.audit import AuditAction, audit
from domain.auth import User, get_current_active_user
from .service import AgentService
from .service import AgentService, validate_request
from .types import AgentChatRequest
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/agent", tags=["agent"])
@router.post("/chat")
@audit(action=AuditAction.CREATE, description="Agent 对话", body_fields=["auto_execute", "approved_mcp_call_ids", "rejected_mcp_call_ids"])
@audit(action=AuditAction.CREATE, description="Agent 对话", body_fields=["auto_execute", "approval_batch_id", "approved_mcp_call_ids", "rejected_mcp_call_ids"])
async def chat(
request: Request,
payload: AgentChatRequest,
@@ -25,12 +25,13 @@ async def chat(
@router.post("/chat/stream")
@audit(action=AuditAction.CREATE, description="Agent 对话(SSE)", body_fields=["auto_execute", "approved_mcp_call_ids", "rejected_mcp_call_ids"])
@audit(action=AuditAction.CREATE, description="Agent 对话(SSE)", body_fields=["auto_execute", "approval_batch_id", "approved_mcp_call_ids", "rejected_mcp_call_ids"])
async def chat_stream(
request: Request,
payload: AgentChatRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
):
await validate_request(payload, current_user)
return StreamingResponse(
AgentService.chat_stream(payload, current_user),
media_type="text/event-stream",
+87
View File
@@ -0,0 +1,87 @@
import json
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException
from tortoise.transactions import in_transaction
from models.database import AgentApprovalBatch, AgentApprovalCall
from .tools.base import tool_error, tool_result_to_content
def now():
return datetime.now(timezone.utc)
def pending_call(call: AgentApprovalCall) -> dict[str, Any]:
return {"id": call.call_id, "name": call.name, "arguments": call.arguments,
"requires_confirmation": call.requires_confirmation, "status": call.status}
def result_message(call: AgentApprovalCall) -> dict[str, Any]:
content = call.result or tool_result_to_content(tool_error("operation_in_progress"))
return {"role": "tool", "mcp_call_id": call.call_id, "content": content}
async def create_batch(user_id: int, assistant: dict[str, Any], tool_index: dict[str, Any]) -> AgentApprovalBatch:
async with in_transaction() as connection:
batch = await AgentApprovalBatch.create(
id=str(uuid.uuid4()), user_id=user_id, assistant_message=assistant,
expires_at=now() + timedelta(hours=24), using_db=connection,
)
await AgentApprovalCall.bulk_create([
AgentApprovalCall(batch_id=batch.id, call_id=call["id"], position=position,
name=call["name"], arguments=call.get("arguments", {}),
requires_confirmation=bool(tool_index.get(call["name"], {}).get("meta", {}).get("requires_confirmation", True)))
for position, call in enumerate(assistant["mcp_calls"])
], using_db=connection)
return batch
async def load_batch(batch_id: str, user_id: int, approved: set[str], rejected: set[str]):
batch = await AgentApprovalBatch.get_or_none(id=batch_id, user_id=user_id)
if batch is None:
raise HTTPException(400, detail="invalid_approval_batch")
calls = await AgentApprovalCall.filter(batch_id=batch.id).order_by("position")
if approved & rejected or (approved | rejected) - {call.call_id for call in calls}:
raise HTTPException(400, detail="invalid_approval_ids")
if batch.expires_at <= now():
await AgentApprovalCall.filter(batch_id=batch.id, status="pending").update(
status="expired", result=tool_result_to_content(tool_error("approval_expired")), updated_at=now(),
)
raise HTTPException(400, detail="approval_expired")
cutoff = now() - timedelta(days=7)
expired_ids = await AgentApprovalBatch.filter(expires_at__lt=now()).values_list("id", flat=True)
if expired_ids:
await AgentApprovalCall.filter(batch_id__in=expired_ids, status="pending").update(
status="expired", result=tool_result_to_content(tool_error("approval_expired")), updated_at=now(),
)
stale = await AgentApprovalBatch.filter(expires_at__lt=cutoff, executing=False).exclude(
continuation_status="running"
).values_list("id", flat=True)
for stale_id in stale:
if not await AgentApprovalCall.filter(batch_id=stale_id, status__in=["pending", "running"]).exists() and not await AgentApprovalCall.filter(batch_id=stale_id, updated_at__gte=cutoff).exists():
await AgentApprovalBatch.filter(id=stale_id).delete()
return batch, calls
async def execute_call(call: AgentApprovalCall, execute, *, rejected: bool = False) -> AgentApprovalCall:
claimed = await AgentApprovalCall.filter(id=call.id, status="pending").update(status="running", updated_at=now())
if not claimed:
return await AgentApprovalCall.get(id=call.id)
if rejected:
content = tool_result_to_content({"canceled": True, "reason": "user_rejected"})
status = "rejected"
else:
# Cancellation intentionally leaves running: retrying an uncertain write is unsafe.
try:
content = await execute(call.name, call.arguments)
except Exception:
content = tool_result_to_content(tool_error("execution_failed"))
try:
status = "failed" if json.loads(content).get("ok") is False else "succeeded"
except (ValueError, AttributeError):
status = "succeeded"
await AgentApprovalCall.filter(id=call.id, status="running").update(status=status, result=content, updated_at=now())
return await AgentApprovalCall.get(id=call.id)
+254
View File
@@ -0,0 +1,254 @@
import codecs
import json
from pathlib import PurePosixPath
from typing import Any
from urllib.parse import urlsplit
import httpx
from fastapi import HTTPException
from domain.auth import User
from domain.permission.execution import (
ExecutionError, execution_context, normalize_path, require_path, require_paths,
)
from domain.permission.types import PathAction
from domain.virtual_fs import VirtualFSService
from .tools import get_tool
from .tools.base import normalize_tool_result, tool_error
def validate_arguments(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
spec = get_tool(name)
if spec is None:
raise ExecutionError("unsupported_capability")
args = {key: value for key, value in arguments.items() if value is not None}
properties = spec.parameters.get("properties", {})
if set(args) - set(properties) or any(key not in args for key in spec.parameters.get("required", [])):
raise ExecutionError("invalid_arguments")
types = {"string": str, "integer": int, "number": (int, float), "boolean": bool, "object": dict, "array": list}
for key, value in args.items():
expected = types.get(properties[key].get("type"))
if expected and (not isinstance(value, expected) or (isinstance(value, bool) and expected != bool)):
raise ExecutionError("invalid_arguments")
if key in {"path", "src", "dst", "save_to"}:
args[key] = normalize_path(value)
for key, low, high in (("page", 1, None), ("page_size", 1, 100), ("top_k", 1, 100),
("max_chars", 1, 100000), ("max_depth", 0, None)):
if key in args and (args[key] < low or (high is not None and args[key] > high)):
raise ExecutionError("invalid_arguments")
for key, values in (("sort_by", {"name", "size", "mtime"}), ("sort_order", {"asc", "desc"}),
("mode", {"filename", "vector"})):
if key in args and args[key] not in values:
raise ExecutionError("invalid_arguments")
if "encoding" in args:
try:
codecs.lookup(args["encoding"])
except LookupError:
raise ExecutionError("invalid_arguments") from None
if "q" in args and not args["q"].strip():
raise ExecutionError("invalid_arguments")
if name == "web_fetch":
url = urlsplit(args["url"])
method = args.get("method", "GET").upper()
if url.scheme not in {"http", "https"} or not url.hostname or method not in {
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
}:
raise ExecutionError("invalid_arguments")
args["method"] = method
for key in ("headers", "params"):
if key in args and any(not isinstance(value, str) for value in args[key].values()):
raise ExecutionError("invalid_arguments")
if "suffix" in args and (not args["suffix"].strip() or any(c in args["suffix"] for c in ("/", "\\", "\x00"))):
raise ExecutionError("invalid_arguments")
return args
async def snapshot_tree(root: str, *, optional: bool = False) -> dict[str, bool]:
"""Enumerate unfiltered paths, failing closed if pagination cannot prove completeness."""
try:
info = await VirtualFSService.stat(root)
except FileNotFoundError:
if optional:
return {}
raise
except HTTPException as exc:
if optional and exc.status_code == 404:
return {}
raise
if not isinstance(info, dict):
raise ExecutionError("permission_scope_unverifiable")
result = {root: bool(info.get("is_dir"))}
stack = [root] if result[root] else []
while stack:
directory = stack.pop()
page, cursor, seen_cursors = 1, None, set()
while True:
try:
listing = await VirtualFSService.list_virtual_dir(directory, page, 100, "name", "asc", cursor)
except Exception as exc:
raise ExecutionError("permission_scope_unverifiable") from exc
if not isinstance(listing, dict) or not isinstance(listing.get("items"), list):
raise ExecutionError("permission_scope_unverifiable")
items = listing["items"]
for item in items:
name = item.get("name")
if not isinstance(name, str) or not name or "/" in name or "\\" in name:
raise ExecutionError("permission_scope_unverifiable")
path = normalize_path(directory.rstrip("/") + "/" + name)
if path in result or len(result) >= 10000:
raise ExecutionError("permission_scope_unverifiable")
result[path] = bool(item.get("is_dir"))
if result[path]:
stack.append(path)
if listing.get("pagination_mode") == "cursor":
if not listing.get("has_next"):
break
cursor = listing.get("next_cursor")
if not cursor or cursor in seen_cursors:
raise ExecutionError("permission_scope_unverifiable")
seen_cursors.add(cursor)
else:
total = listing.get("total")
if not isinstance(total, int) or (not items and page * 100 < total):
raise ExecutionError("permission_scope_unverifiable")
if len(items) < min(100, max(0, total - (page - 1) * 100)):
raise ExecutionError("permission_scope_unverifiable")
if page * 100 >= total:
break
page += 1
return result
async def prepare_scope(name: str, args: dict[str, Any], user: User) -> dict[str, Any]:
from domain.processors import ProcessorService
scope: dict[str, Any] = {"user_id": user.id, "permissions": [], "trees": {}}
permissions: set[tuple[str, str]] = set()
async def output_path(path: str):
permissions.add((path, PathAction.WRITE))
parent = str(PurePosixPath(path).parent)
while parent != "/":
try:
await VirtualFSService.stat(parent)
break
except FileNotFoundError:
permissions.add((parent, PathAction.WRITE))
except HTTPException as exc:
if exc.status_code != 404:
raise
permissions.add((parent, PathAction.WRITE))
parent = str(PurePosixPath(parent).parent)
async def tree(root: str, actions: list[str], optional: bool = False):
await require_paths([(root, action) for action in actions])
snapshot = await snapshot_tree(root, optional=optional)
if snapshot:
scope["trees"][root] = snapshot
for path in snapshot or {root: False}:
permissions.update((path, action) for action in actions)
return snapshot
if name in {"vfs_move", "vfs_rename", "vfs_copy"}:
src, dst = args["src"], args["dst"]
if src == "/" or dst == "/" or src == dst or dst.startswith(src + "/") or src.startswith(dst + "/"):
raise ExecutionError("invalid_arguments")
actions = [PathAction.READ] + ([] if name == "vfs_copy" else [PathAction.DELETE])
await require_paths([(src, action) for action in actions] + [(dst, PathAction.WRITE)])
source = await tree(src, actions)
for path in source:
permissions.add((dst + path[len(src):], PathAction.WRITE))
# Native overwrite can remove the entire destination subtree.
if args.get("overwrite"):
await tree(dst, [PathAction.WRITE, PathAction.DELETE], optional=True)
await output_path(dst)
elif name == "vfs_delete":
if args["path"] == "/":
raise ExecutionError("invalid_arguments")
await tree(args["path"], [PathAction.DELETE, PathAction.READ])
elif name == "processors_run":
root = args["path"]
await require_path(root, PathAction.READ)
processor = ProcessorService.get_processor(args["processor_type"])
if processor is None:
raise ExecutionError("unsupported_capability")
config = args.get("config", {})
for field in getattr(processor, "config_schema", []):
value = config.get(field["key"], field.get("default"))
if field.get("required") and (value is None or value == ""):
raise ExecutionError("invalid_arguments")
if value is not None:
kind = field.get("type")
if kind == "number" and (isinstance(value, bool) or not isinstance(value, (float, int))):
raise ExecutionError("invalid_arguments")
if kind == "string" and not isinstance(value, str):
raise ExecutionError("invalid_arguments")
if kind == "select" and value not in [option["value"] for option in field.get("options", [])]:
raise ExecutionError("invalid_arguments")
source = await tree(root, [PathAction.READ])
is_dir = source[root]
produces = bool(getattr(processor, "produces_file", False))
if is_dir and getattr(processor, "supports_directory", False) and produces and not user.is_admin:
raise ExecutionError("permission_scope_unverifiable")
directory_mode = is_dir and ("max_depth" in args or "suffix" in args)
overwrite = args.get("overwrite", directory_mode)
if is_dir and args.get("save_to"):
raise ExecutionError("invalid_arguments")
if produces:
if is_dir:
if not overwrite and not args.get("suffix"):
raise ExecutionError("invalid_arguments")
for path, child_is_dir in source.items():
if not child_is_dir:
target = path
if not overwrite:
p = PurePosixPath(path)
target = str(p.with_name(p.stem + args["suffix"] + p.suffix))
permissions.add((target, PathAction.WRITE))
else:
target = root if overwrite else args.get("save_to")
if not target:
raise ExecutionError("permission_scope_unverifiable")
await output_path(target)
else:
action = PathAction.WRITE if name in {"vfs_write_text", "vfs_mkdir"} else PathAction.READ
if "path" in args:
if action == PathAction.WRITE and args["path"] == "/":
raise ExecutionError("invalid_arguments")
permissions.add((args["path"], action))
if action == PathAction.WRITE:
await require_path(args["path"], action)
await output_path(args["path"])
await require_paths(sorted(permissions))
scope["permissions"] = [list(item) for item in sorted(permissions)]
return scope
def exception_result(exc: Exception) -> dict[str, Any]:
if isinstance(exc, ExecutionError):
return tool_error(exc.code)
if isinstance(exc, (ValueError, TypeError, LookupError, OverflowError)):
return tool_error("invalid_arguments")
if isinstance(exc, (FileNotFoundError,)) or isinstance(exc, HTTPException) and exc.status_code == 404:
return tool_error("path_not_found")
if isinstance(exc, HTTPException):
code = {403: "permission_denied", 400: "invalid_arguments", 409: "path_conflict", 501: "unsupported_capability"}.get(exc.status_code, "execution_failed")
return tool_error(code)
if isinstance(exc, httpx.TimeoutException):
return tool_error("request_timeout")
return tool_error("execution_failed")
async def execute_tool(name: str, arguments: dict[str, Any], user: User | None, current_path: str | None = None) -> dict[str, Any]:
try:
if user is None or user.disabled:
raise ExecutionError("permission_denied")
args = validate_arguments(name, arguments)
with execution_context({"user_id": user.id, "current_path": current_path}):
scope = await prepare_scope(name, args, user)
with execution_context(scope):
result = await get_tool(name).handler(args)
# Pydantic search results must remain structured JSON rather than repr strings.
return normalize_tool_result(json.loads(json.dumps(result, ensure_ascii=False, default=lambda value: value.model_dump(mode="json"))))
except Exception as exc:
return exception_result(exc)
+58 -34
View File
@@ -1,6 +1,7 @@
import inspect
import json
from contextlib import asynccontextmanager
from contextvars import ContextVar
from datetime import timedelta
from typing import Annotated, Any, Literal
from urllib.parse import quote, unquote
@@ -10,6 +11,7 @@ from mcp.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.mcpserver import Context, MCPServer
from mcp.types import ToolAnnotations
from pydantic import Field
@@ -17,11 +19,24 @@ from pydantic import Field
from domain.auth import AuthService, User
from domain.processors import ProcessorService
from .tools import get_tool, mcp_tool_descriptors
from .tools.base import McpToolDescriptor, normalize_tool_result, tool_result_to_content
from .tools import mcp_tool_descriptors
from .tools.base import McpToolDescriptor, tool_result_to_content
from .execution import execute_tool
from domain.permission.execution import ExecutionError, normalize_path
INTERNAL_MCP_BASE_URL = "http://127.0.0.1:8000/"
CURRENT_PATH_HEADER = "x-foxel-current-path"
_resource_context: ContextVar[Context | None] = ContextVar("foxel_resource_context", default=None)
class FoxelMCPServer(MCPServer):
async def read_resource(self, uri, context=None):
# The SDK does not inject Context into fixed-URI resource handlers.
token = _resource_context.set(context)
try:
return await super().read_resource(uri, context)
finally:
_resource_context.reset(token)
def _normalize_path(path: str | None) -> str | None:
@@ -32,14 +47,17 @@ def _normalize_path(path: str | None) -> str | None:
return None
if not value.startswith("/"):
value = "/" + value
return value.rstrip("/") or "/"
return normalize_path(value)
def _header_current_path(ctx: Context | None) -> str | None:
request = ctx.request_context.request if ctx and ctx.request_context else None
if request is None:
return None
return _normalize_path(request.headers.get(CURRENT_PATH_HEADER))
try:
return _normalize_path(request.headers.get(CURRENT_PATH_HEADER))
except ExecutionError:
return None
def _field_annotation(schema: dict[str, Any], required: bool) -> tuple[Any, Any]:
@@ -69,7 +87,15 @@ def _field_annotation(schema: dict[str, Any], required: bool) -> tuple[Any, Any]
if not required and default is None:
annotation = annotation | None
if description:
if enum_values:
annotation = Annotated[annotation, Field(description=description)]
elif raw_type in {"string", "integer", "number", "boolean"}:
annotation = Annotated[annotation, Field(
description=description, strict=True,
ge=schema.get("minimum") if raw_type in {"integer", "number"} else None,
le=schema.get("maximum") if raw_type in {"integer", "number"} else None,
)]
elif description:
annotation = Annotated[annotation, Field(description=description)]
return annotation, default
@@ -94,15 +120,8 @@ def _build_tool_signature(descriptor: McpToolDescriptor) -> inspect.Signature:
def _build_tool_wrapper(descriptor: McpToolDescriptor):
async def wrapper(**kwargs: Any) -> dict[str, Any]:
spec = get_tool(descriptor.name)
if not spec:
return normalize_tool_result({"error": f"unknown_tool: {descriptor.name}"})
try:
result = await spec.handler(kwargs)
return normalize_tool_result(result)
except Exception as exc: # noqa: BLE001
return normalize_tool_result({"error": str(exc)})
async def wrapper(ctx: Context, **kwargs: Any) -> dict[str, Any]:
return await _tool_resource(descriptor.name, kwargs, _header_current_path(ctx))
wrapper.__name__ = descriptor.name
wrapper.__doc__ = descriptor.description
@@ -110,6 +129,16 @@ def _build_tool_wrapper(descriptor: McpToolDescriptor):
return wrapper
async def _authenticated_user() -> User | None:
token = get_access_token()
if token is None:
return None
try:
return await AuthService.get_current_active_user(await AuthService.get_current_user(token.token))
except Exception:
return None
class FoxelMcpTokenVerifier:
async def verify_token(self, token: str) -> AccessToken | None:
try:
@@ -119,7 +148,7 @@ class FoxelMcpTokenVerifier:
return AccessToken(token=token, client_id=user.username, scopes=[])
MCP_SERVER = MCPServer(
MCP_SERVER = FoxelMCPServer(
name="Foxel MCP",
instructions="Foxel 内置 MCP 服务,提供文件系统、网页抓取、时间与处理器相关能力。",
token_verifier=FoxelMcpTokenVerifier(),
@@ -150,7 +179,7 @@ for descriptor in mcp_tool_descriptors():
mime_type="application/json",
)
def current_path_resource() -> dict[str, Any]:
return {"current_path": None}
return {"current_path": _header_current_path(_resource_context.get())}
@MCP_SERVER.resource(
@@ -162,9 +191,11 @@ def current_path_resource() -> dict[str, Any]:
)
def tool_confirmation_policy_resource() -> dict[str, Any]:
return {
"read_tools": [tool.name for tool in mcp_tool_descriptors() if not tool.requires_confirmation],
"read_tools": [tool.name for tool in mcp_tool_descriptors() if tool.annotations.get("readOnlyHint")],
"unconfirmed_tools": [tool.name for tool in mcp_tool_descriptors() if not tool.requires_confirmation],
"write_tools": [tool.name for tool in mcp_tool_descriptors() if tool.requires_confirmation],
"rule": "直接调用 MCP tool 时不额外审批;通过 agent 代表用户执行写操作时需要审批。",
"web_fetch": "所有 HTTP 方法免审批,包含可能修改外部系统的请求;免审批不代表只读。",
}
@@ -179,15 +210,8 @@ def processors_index_resource() -> dict[str, Any]:
return {"processors": ProcessorService.list_processors()}
async def _tool_resource(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
spec = get_tool(tool_name)
if not spec:
return normalize_tool_result({"error": f"unknown_tool: {tool_name}"})
try:
result = await spec.handler(arguments)
return normalize_tool_result(result)
except Exception as exc: # noqa: BLE001
return normalize_tool_result({"error": str(exc)})
async def _tool_resource(tool_name: str, arguments: dict[str, Any], current_path: str | None = None) -> dict[str, Any]:
return await execute_tool(tool_name, arguments, await _authenticated_user(), current_path)
@MCP_SERVER.resource(
@@ -197,8 +221,8 @@ async def _tool_resource(tool_name: str, arguments: dict[str, Any]) -> dict[str,
description="读取指定路径的文件或目录元信息;path 需要 URL 编码。",
mime_type="application/json",
)
async def vfs_stat_resource(path: str) -> dict[str, Any]:
return await _tool_resource("vfs_stat", {"path": "/" + unquote(path).lstrip("/")})
async def vfs_stat_resource(path: str, ctx: Context) -> dict[str, Any]:
return await _tool_resource("vfs_stat", {"path": "/" + unquote(path).lstrip("/")}, _header_current_path(ctx))
@MCP_SERVER.resource(
@@ -208,8 +232,8 @@ async def vfs_stat_resource(path: str) -> dict[str, Any]:
description="读取文本文件内容;path 需要 URL 编码。",
mime_type="application/json",
)
async def vfs_text_resource(path: str) -> dict[str, Any]:
return await _tool_resource("vfs_read_text", {"path": "/" + unquote(path).lstrip("/")})
async def vfs_text_resource(path: str, ctx: Context) -> dict[str, Any]:
return await _tool_resource("vfs_read_text", {"path": "/" + unquote(path).lstrip("/")}, _header_current_path(ctx))
@MCP_SERVER.resource(
@@ -219,8 +243,8 @@ async def vfs_text_resource(path: str) -> dict[str, Any]:
description="列出目录内容;path 需要 URL 编码。",
mime_type="application/json",
)
async def vfs_dir_resource(path: str) -> dict[str, Any]:
return await _tool_resource("vfs_list_dir", {"path": "/" + unquote(path).lstrip("/")})
async def vfs_dir_resource(path: str, ctx: Context) -> dict[str, Any]:
return await _tool_resource("vfs_list_dir", {"path": "/" + unquote(path).lstrip("/")}, _header_current_path(ctx))
@MCP_SERVER.resource(
@@ -230,8 +254,8 @@ async def vfs_dir_resource(path: str) -> dict[str, Any]:
description="搜索文件;query 需要 URL 编码。",
mime_type="application/json",
)
async def vfs_search_resource(query: str) -> dict[str, Any]:
return await _tool_resource("vfs_search", {"q": unquote(query)})
async def vfs_search_resource(query: str, ctx: Context) -> dict[str, Any]:
return await _tool_resource("vfs_search", {"q": unquote(query)}, _header_current_path(ctx))
@MCP_SERVER.prompt(name="browse_path", title="Browse Path", description="生成浏览目录的推荐提示词。")
+216 -395
View File
@@ -1,443 +1,264 @@
import asyncio
import json
import uuid
from typing import Any, Dict, List, Optional, Tuple
from contextlib import aclosing
from typing import Any
import httpx
from fastapi import HTTPException
from domain.ai import AIProviderService, MissingModelError, chat_completion, chat_completion_stream
from domain.auth import User
from domain.permission.execution import ExecutionError, normalize_path
from models.database import AgentApprovalBatch, AgentApprovalCall
from .approvals import create_batch, execute_call, load_batch, pending_call, result_message
from .mcp import mcp_client_session, mcp_content_to_text
from .tools import tool_result_to_content
from .types import AgentChatRequest, PendingMcpCall
from .tools.base import tool_error, tool_result_to_content
from .types import AgentChatRequest
def _normalize_path(path: Optional[str]) -> Optional[str]:
if not path:
return None
value = str(path).strip().replace("\\", "/")
if not value:
return None
if not value.startswith("/"):
value = "/" + value
return value.rstrip("/") or "/"
def _build_system_prompt(current_path: Optional[str]) -> str:
lines = [
"你是 Foxel 的 AI 助手。",
"你可以通过 MCP 工具对文件/目录进行查询、读写、移动、复制、删除,以及运行处理器(processor)。",
"",
"可用工具:",
"- time:获取服务器当前时间(精确到秒,英文星期),支持 year/month/day/hour/minute/second 偏移。",
"- web_fetch:抓取网页(HTTP 请求),支持 GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS,返回状态/标题/正文/链接等。",
"- vfs_list_dir:浏览目录(列出 entries + pagination)。",
"- vfs_stat:查看文件/目录信息。",
"- vfs_read_text:读取文本文件内容(不支持二进制)。",
"- vfs_search:搜索文件(vector/filename)。",
"- vfs_write_text:写入文本文件内容(覆盖)。",
"- vfs_mkdir:创建目录。",
"- vfs_delete:删除文件或目录。",
"- vfs_move:移动路径。",
"- vfs_copy:复制路径。",
"- vfs_rename:重命名路径。",
"- processors_list:获取可用处理器列表(含 type/name/config_schema/produces_file/supports_directory)。",
"- processors_run:运行处理器处理文件或目录(会返回 task_id 或 task_ids)。",
"",
"规则:",
"1) 读操作(web_fetch/vfs_list_dir/vfs_stat/vfs_read_text/vfs_search)可直接调用工具。",
"2) 写/改/删操作(vfs_write_text/vfs_mkdir/vfs_delete/vfs_move/vfs_copy/vfs_rename/processors_run)默认需要用户确认;只有在开启自动执行时才应直接执行。",
"3) 用户未给出明确路径时先追问;若提供了“当前文件管理目录”,可以基于它把相对描述补全为绝对路径(以 / 开头)。",
"4) 修改文件内容:先读取(vfs_read_text)→给出改动点→确认后再写入(vfs_write_text)。",
"5) processors_run 返回任务 id 后,说明任务已提交,可在任务队列查看进度。",
"6) 回答语言跟随用户;用户用英文则用英文,用户用中文则用中文。回答尽量简洁。",
]
if current_path:
lines.append("")
lines.append(f"当前文件管理目录:{current_path}")
return "\n".join(lines)
def _ensure_mcp_call_ids(message: Dict[str, Any]) -> Dict[str, Any]:
mcp_calls = message.get("mcp_calls")
if not isinstance(mcp_calls, list):
return message
changed = False
for idx, call in enumerate(mcp_calls):
if not isinstance(call, dict):
continue
call_id = call.get("id")
if isinstance(call_id, str) and call_id.strip():
continue
call["id"] = f"call_{idx}"
changed = True
if changed:
message["mcp_calls"] = mcp_calls
return message
def _extract_pending(mcp_call: Dict[str, Any], requires_confirmation: bool) -> PendingMcpCall:
arguments = mcp_call.get("arguments") if isinstance(mcp_call.get("arguments"), dict) else {}
return PendingMcpCall(
id=str(mcp_call.get("id") or ""),
name=str(mcp_call.get("name") or ""),
arguments=arguments,
requires_confirmation=requires_confirmation,
def _build_system_prompt(current_path: str | None) -> str:
text = (
"你是 Foxel 的 AI 助手。使用提供的 MCP 工具查询、读取、写入、移动、复制、删除文件,"
"抓取网页、获取时间或提交处理器任务。回答语言跟随用户,尽量简洁。\n"
"查询工具可直接执行。web_fetch 的所有 HTTP 方法均免审批,但可能修改外部系统。\n"
"文件写入、创建、删除、移动、复制、重命名和 processors_run 默认需要用户确认;"
"开启自动执行时可直接执行。审批不授予路径权限。\n"
"路径必须是以 / 开头的绝对路径;未提供明确路径时追问,或基于当前文件管理目录补全。\n"
"修改文本前先读取并说明改动,再申请写入。processors_run 返回 task_id 只代表已提交,"
"请用户到任务队列查看进度。当前不提供任务查询、外部 MCP、后台持续 Agent 或长期记忆。"
)
return text + (f"\n当前文件管理目录:{current_path}" if current_path else "")
def _find_last_assistant_mcp_calls(messages: List[Dict[str, Any]]) -> Tuple[int, Dict[str, Any]]:
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if not isinstance(msg, dict):
def _ensure_mcp_call_ids(message: dict[str, Any]) -> dict[str, Any]:
calls = message.get("mcp_calls")
if not isinstance(calls, list):
return message
seen, valid = set(), []
for call in calls:
if not isinstance(call, dict) or not isinstance(call.get("name"), str):
continue
if msg.get("role") != "assistant":
continue
mcp_calls = msg.get("mcp_calls")
if isinstance(mcp_calls, list) and mcp_calls:
return idx, msg
raise HTTPException(status_code=400, detail="没有可确认的待执行操作")
call = dict(call)
call_id = call.get("id")
if not isinstance(call_id, str) or not call_id.strip() or call_id in seen or len(call_id) > 255:
call_id = "call_" + uuid.uuid4().hex
seen.add(call_id)
call["id"] = call_id
if not isinstance(call.get("arguments"), dict):
call["arguments"] = {}
valid.append(call)
return {**message, "mcp_calls": valid}
def _existing_mcp_result_ids(messages: List[Dict[str, Any]]) -> set[str]:
ids: set[str] = set()
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get("role") != "tool":
continue
call_id = msg.get("mcp_call_id")
if isinstance(call_id, str) and call_id.strip():
ids.add(call_id)
return ids
def _tool_requires_confirmation(tool_descriptor: Dict[str, Any]) -> bool:
meta = tool_descriptor.get("meta") if isinstance(tool_descriptor.get("meta"), dict) else {}
if "requires_confirmation" in meta:
return bool(meta.get("requires_confirmation"))
annotations = tool_descriptor.get("annotations") if isinstance(tool_descriptor.get("annotations"), dict) else {}
return not bool(annotations.get("readOnlyHint"))
def _tool_requires_confirmation(descriptor: dict[str, Any]) -> bool:
meta = descriptor.get("meta") or {}
return bool(meta.get("requires_confirmation", not (descriptor.get("annotations") or {}).get("readOnlyHint")))
async def _choose_chat_ability() -> str:
tools_model = await AIProviderService.get_default_model("tools")
return "tools" if tools_model else "chat"
ability = "tools" if await AIProviderService.get_default_model("tools") else "chat"
model = await AIProviderService.get_default_model(ability)
if model is None:
raise MissingModelError(f"未配置默认 {ability} 模型,请前往系统设置完成配置。")
if getattr(model, "provider", None) is None:
await model.fetch_related("provider")
if str(model.provider.api_format).lower() not in {"openai", "anthropic", "ollama"}:
raise MissingModelError("Agent 当前支持 OpenAI、Anthropic 和 Ollama 对话接口。")
return ability
def _sse(event: str, data: Any) -> bytes:
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
return ("event: " + event + "\ndata: " + json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n\n").encode()
def _format_exc(exc: BaseException) -> str:
text = str(exc)
return text if text else exc.__class__.__name__
async def _list_mcp_tools(session) -> List[Dict[str, Any]]:
async def _list_mcp_tools(session) -> list[dict[str, Any]]:
result = await session.list_tools()
tools: List[Dict[str, Any]] = []
for item in result.tools:
annotations = getattr(item, "annotations", None)
meta = getattr(item, "meta", None)
tools.append(
{
"name": str(getattr(item, "name", "") or ""),
"description": str(getattr(item, "description", "") or ""),
"input_schema": getattr(item, "input_schema", None) or {},
"annotations": annotations.model_dump(exclude_none=True) if annotations is not None else {},
"meta": meta if isinstance(meta, dict) else {},
}
)
return tools
return [{"name": item.name, "description": item.description or "", "input_schema": item.input_schema or {},
"annotations": item.annotations.model_dump(exclude_none=True) if item.annotations else {},
"meta": item.meta or {}} for item in result.tools]
async def _execute_mcp_call(session, name: str, arguments: Dict[str, Any]) -> str:
result = await session.call_tool(name, arguments)
return mcp_content_to_text(result.content, result.structured_content)
async def _execute_mcp_call(session, name: str, arguments: dict[str, Any]) -> str:
try:
result = await session.call_tool(name, arguments)
if getattr(result, "is_error", False):
return tool_result_to_content(tool_error("invalid_arguments"))
return mcp_content_to_text(result.content, result.structured_content)
except Exception:
return tool_result_to_content(tool_error("execution_failed"))
async def validate_request(req: AgentChatRequest, user: User):
approved, rejected = set(req.approved_mcp_call_ids), set(req.rejected_mcp_call_ids)
if approved & rejected:
raise HTTPException(400, detail="invalid_approval_ids")
if (approved or rejected) and not req.approval_batch_id:
raise HTTPException(400, detail="approval_batch_required: 请重新生成待审批操作。")
if req.context and req.context.current_path:
try:
normalize_path(req.context.current_path)
except ExecutionError:
raise HTTPException(400, detail="invalid_arguments") from None
if req.approval_batch_id:
return await load_batch(req.approval_batch_id, user.id, approved, rejected)
answered = {m.get("mcp_call_id") for m in req.messages if m.get("role") == "tool"}
if any(c.get("id") not in answered for m in req.messages if m.get("role") == "assistant"
for c in (m.get("mcp_calls") or []) if isinstance(c, dict)):
raise HTTPException(400, detail="approval_batch_required: 请重新生成待审批操作。")
return None
class AgentService:
@classmethod
async def chat(cls, req: AgentChatRequest, user: Optional[User]) -> Dict[str, Any]:
history: List[Dict[str, Any]] = list(req.messages or [])
current_path = _normalize_path(req.context.current_path if req.context else None)
system_prompt = _build_system_prompt(current_path)
internal_messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] + history
new_messages: List[Dict[str, Any]] = []
pending: List[PendingMcpCall] = []
async def _run(cls, req: AgentChatRequest, user: User, streaming: bool):
validated = await validate_request(req, user)
path = normalize_path(req.context.current_path) if req.context and req.context.current_path else None
history = [dict(m) for m in req.messages if m.get("role") in {"user", "assistant", "tool"}]
messages: list[dict[str, Any]] = []
batch_id, pending = req.approval_batch_id, []
replace = bool(validated)
continuation_owned = False
execution_owned = False
approved_ids = {i for i in (req.approved_mcp_call_ids or []) if isinstance(i, str) and i.strip()}
rejected_ids = {i for i in (req.rejected_mcp_call_ids or []) if isinstance(i, str) and i.strip()}
def payload(reason: str):
return {"messages": history + messages if replace else messages,
"replace_messages": replace, "pending_mcp_calls": pending,
"approval_batch_id": batch_id if pending else None, "finish_reason": reason}
async with mcp_client_session(user, current_path) as mcp_session:
tools_schema = await _list_mcp_tools(mcp_session)
tool_index = {tool["name"]: tool for tool in tools_schema if tool.get("name")}
if approved_ids or rejected_ids:
_, last_call_msg = _find_last_assistant_mcp_calls(internal_messages)
last_call_msg = _ensure_mcp_call_ids(last_call_msg)
mcp_calls = last_call_msg.get("mcp_calls") or []
call_map: Dict[str, Dict[str, Any]] = {
str(call.get("id")): call
for call in mcp_calls
if isinstance(call, dict) and isinstance(call.get("id"), str)
}
existing_ids = _existing_mcp_result_ids(internal_messages)
for call_id in approved_ids | rejected_ids:
if call_id in existing_ids:
continue
mcp_call = call_map.get(call_id)
if not mcp_call:
continue
name = str(mcp_call.get("name") or "")
arguments = mcp_call.get("arguments") if isinstance(mcp_call.get("arguments"), dict) else {}
tool_desc = tool_index.get(name)
if call_id in rejected_ids:
content = tool_result_to_content({"canceled": True, "reason": "user_rejected"})
elif not tool_desc:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
else:
try:
content = await _execute_mcp_call(mcp_session, name, arguments)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
ability = await _choose_chat_ability()
for _ in range(8):
try:
assistant = await chat_completion(
internal_messages,
ability=ability,
tools=tools_schema,
tool_choice="auto",
timeout=60.0,
)
except MissingModelError as exc:
raise HTTPException(status_code=400, detail=_format_exc(exc)) from exc
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=f"对话请求失败: {_format_exc(exc)}") from exc
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"对话请求异常: {_format_exc(exc)}") from exc
assistant = _ensure_mcp_call_ids(assistant if isinstance(assistant, dict) else {"role": "assistant", "content": ""})
internal_messages.append(assistant)
new_messages.append(assistant)
mcp_calls = assistant.get("mcp_calls")
if not isinstance(mcp_calls, list) or not mcp_calls:
break
pending = []
for call in mcp_calls:
if not isinstance(call, dict):
continue
call_id = str(call.get("id") or "")
name = str(call.get("name") or "")
arguments = call.get("arguments") if isinstance(call.get("arguments"), dict) else {}
tool_desc = tool_index.get(name)
if not tool_desc:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
continue
if _tool_requires_confirmation(tool_desc) and not req.auto_execute:
pending.append(_extract_pending(call, True))
continue
try:
content = await _execute_mcp_call(mcp_session, name, arguments)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
if pending:
break
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_mcp_calls"] = [item.model_dump() for item in pending]
return payload
@classmethod
async def chat_stream(cls, req: AgentChatRequest, user: Optional[User]):
history: List[Dict[str, Any]] = list(req.messages or [])
current_path = _normalize_path(req.context.current_path if req.context else None)
system_prompt = _build_system_prompt(current_path)
internal_messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] + history
new_messages: List[Dict[str, Any]] = []
pending: List[PendingMcpCall] = []
approved_ids = {i for i in (req.approved_mcp_call_ids or []) if isinstance(i, str) and i.strip()}
rejected_ids = {i for i in (req.rejected_mcp_call_ids or []) if isinstance(i, str) and i.strip()}
async def finish(reason: str):
data = payload(reason)
if continuation_owned:
await AgentApprovalBatch.filter(id=req.approval_batch_id, continuation_status="running").update(
continuation_status="completed", continuation_result=data,
)
return data
try:
async with mcp_client_session(user, current_path) as mcp_session:
tools_schema = await _list_mcp_tools(mcp_session)
tool_index = {tool["name"]: tool for tool in tools_schema if tool.get("name")}
async with mcp_client_session(user, path) as session:
tools = await _list_mcp_tools(session)
index = {tool["name"]: tool for tool in tools}
if approved_ids or rejected_ids:
_, last_call_msg = _find_last_assistant_mcp_calls(internal_messages)
last_call_msg = _ensure_mcp_call_ids(last_call_msg)
mcp_calls = last_call_msg.get("mcp_calls") or []
call_map: Dict[str, Dict[str, Any]] = {
str(call.get("id")): call
for call in mcp_calls
if isinstance(call, dict) and isinstance(call.get("id"), str)
}
async def execute(name, arguments):
return await _execute_mcp_call(session, name, arguments)
existing_ids = _existing_mcp_result_ids(internal_messages)
for call_id in approved_ids | rejected_ids:
if call_id in existing_ids:
continue
mcp_call = call_map.get(call_id)
if not mcp_call:
continue
name = str(mcp_call.get("name") or "")
arguments = mcp_call.get("arguments") if isinstance(mcp_call.get("arguments"), dict) else {}
tool_desc = tool_index.get(name)
if call_id in rejected_ids:
content = tool_result_to_content({"canceled": True, "reason": "user_rejected"})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
continue
if not tool_desc:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
continue
yield _sse("mcp_call_start", {"mcp_call_id": call_id, "name": name})
try:
content = await _execute_mcp_call(mcp_session, name, arguments)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
if validated:
batch, calls = validated
if batch.continuation_result is not None:
yield "done", batch.continuation_result
return
approved, rejected = set(req.approved_mcp_call_ids), set(req.rejected_mcp_call_ids)
# Replace the last tool-call turn with its authoritative batch.
last = next((i for i in range(len(history) - 1, -1, -1)
if history[i].get("role") == "assistant" and history[i].get("mcp_calls")), None)
history = history + [batch.assistant_message] if last is None else history[:last] + [batch.assistant_message]
execution_owned = bool(await AgentApprovalBatch.filter(id=batch.id, executing=False).update(executing=True))
calls = await AgentApprovalCall.filter(batch_id=batch.id).order_by("position")
if not execution_owned or any(call.status == "running" for call in calls):
messages.extend(result_message(call) for call in calls if call.result is not None)
pending = [pending_call(call) for call in calls if call.status in {"pending", "running"}]
yield "pending", {"pending_mcp_calls": pending, "approval_batch_id": batch.id}
yield "done", {**payload("operation_in_progress"), "approval_batch_id": batch.id}
return
for call in calls:
if call.call_id in approved | rejected:
if call.status == "pending" and call.call_id in approved:
yield "mcp_call_start", {"mcp_call_id": call.call_id, "name": call.name}
call = await execute_call(call, execute, rejected=call.call_id in rejected)
yield "mcp_call_end", {"mcp_call_id": call.call_id, "name": call.name, "message": result_message(call)}
if call.status == "running":
break
calls = await AgentApprovalCall.filter(batch_id=batch.id).order_by("position")
messages.extend(result_message(call) for call in calls if call.result is not None)
pending = [pending_call(call) for call in calls if call.status in {"pending", "running"}]
if pending:
yield "pending", {"pending_mcp_calls": pending, "approval_batch_id": batch_id}
yield "done", await finish("operation_in_progress" if any(c["status"] == "running" for c in pending) else "pending_approval")
return
continuation_owned = bool(await AgentApprovalBatch.filter(id=batch.id, continuation_status="pending").update(continuation_status="running"))
if not continuation_owned:
batch = await AgentApprovalBatch.get(id=batch.id)
data = batch.continuation_result or {**payload("operation_in_progress"), "approval_batch_id": batch.id}
yield "done", data
return
ability = await _choose_chat_ability()
internal = [{"role": "system", "content": _build_system_prompt(path)}] + history + messages
for _ in range(8):
assistant_event_id = str(uuid.uuid4())
yield _sse("assistant_start", {"id": assistant_event_id})
assistant_message: Dict[str, Any] | None = None
try:
async for event in chat_completion_stream(
internal_messages,
ability=ability,
tools=tools_schema,
tool_choice="auto",
timeout=60.0,
):
event_type = event.get("type")
if event_type == "delta":
delta = event.get("delta")
if isinstance(delta, str) and delta:
yield _sse("assistant_delta", {"id": assistant_event_id, "delta": delta})
elif event_type == "message":
msg = event.get("message")
if isinstance(msg, dict):
assistant_message = msg
except MissingModelError as exc:
raise HTTPException(status_code=400, detail=_format_exc(exc)) from exc
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=f"对话请求失败: {_format_exc(exc)}") from exc
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"对话请求异常: {_format_exc(exc)}") from exc
if not assistant_message:
assistant_message = {"role": "assistant", "content": ""}
assistant_message = _ensure_mcp_call_ids(assistant_message)
internal_messages.append(assistant_message)
new_messages.append(assistant_message)
yield _sse("assistant_end", {"id": assistant_event_id, "message": assistant_message})
mcp_calls = assistant_message.get("mcp_calls")
if not isinstance(mcp_calls, list) or not mcp_calls:
break
pending = []
for call in mcp_calls:
if not isinstance(call, dict):
event_id = str(uuid.uuid4())
yield "assistant_start", {"id": event_id}
assistant = None
if streaming:
async for event in chat_completion_stream(internal, ability=ability, tools=tools, tool_choice="auto", timeout=60.0):
if event.get("type") == "delta" and event.get("delta"):
yield "assistant_delta", {"id": event_id, "delta": event["delta"]}
elif event.get("type") == "message":
assistant = event.get("message")
else:
assistant = await chat_completion(internal, ability=ability, tools=tools, tool_choice="auto", timeout=60.0)
assistant = _ensure_mcp_call_ids(assistant or {"role": "assistant", "content": ""})
internal.append(assistant)
messages.append(assistant)
yield "assistant_end", {"id": event_id, "message": assistant}
calls = assistant.get("mcp_calls") or []
if not calls:
yield "done", await finish("completed")
return
needs_approval = not req.auto_execute and any(
_tool_requires_confirmation(index.get(call["name"], {})) for call in calls
)
batch = await create_batch(user.id, assistant, index) if needs_approval else None
records = await AgentApprovalCall.filter(batch_id=batch.id).order_by("position") if batch else []
for position, call in enumerate(calls):
if needs_approval and _tool_requires_confirmation(index.get(call["name"], {})):
continue
call_id = str(call.get("id") or "")
name = str(call.get("name") or "")
arguments = call.get("arguments") if isinstance(call.get("arguments"), dict) else {}
tool_desc = tool_index.get(name)
yield "mcp_call_start", {"mcp_call_id": call["id"], "name": call["name"]}
if batch:
record = await execute_call(records[position], execute)
message = result_message(record)
else:
message = {"role": "tool", "mcp_call_id": call["id"], "content": await execute(call["name"], call["arguments"])}
messages.append(message)
internal.append(message)
yield "mcp_call_end", {"mcp_call_id": call["id"], "name": call["name"], "message": message}
if batch:
batch_id = batch.id
records = await AgentApprovalCall.filter(batch_id=batch.id).order_by("position")
pending = [pending_call(call) for call in records if call.status in {"pending", "running"}]
yield "pending", {"pending_mcp_calls": pending, "approval_batch_id": batch_id}
yield "done", await finish("pending_approval")
return
messages.append({"role": "assistant", "content": "已达到本次 8 轮调用上限,已执行结果保留。请继续对话。"})
yield "done", await finish("iteration_limit")
except asyncio.CancelledError:
raise
except Exception as exc:
if isinstance(exc, MissingModelError):
content, reason = str(exc), "model_unavailable"
elif isinstance(exc, httpx.TimeoutException):
content, reason = "模型请求超时。", "request_timeout"
else:
content, reason = "请求执行失败,请稍后重试。", "execution_failed"
messages.append({"role": "assistant", "content": content})
yield "done", await finish(reason)
finally:
if execution_owned:
await AgentApprovalBatch.filter(id=req.approval_batch_id).update(executing=False)
if not tool_desc:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
continue
if _tool_requires_confirmation(tool_desc) and not req.auto_execute:
pending.append(_extract_pending(call, True))
continue
yield _sse("mcp_call_start", {"mcp_call_id": call_id, "name": name})
try:
content = await _execute_mcp_call(mcp_session, name, arguments)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
if pending:
yield _sse("pending", {"pending_mcp_calls": [item.model_dump() for item in pending]})
break
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_mcp_calls"] = [item.model_dump() for item in pending]
yield _sse("done", payload)
@classmethod
async def chat(cls, req: AgentChatRequest, user: User) -> dict[str, Any]:
async with aclosing(cls._run(req, user, streaming=False)) as events:
async for event, data in events:
if event == "done":
return data
return {"messages": []}
@classmethod
async def chat_stream(cls, req: AgentChatRequest, user: User):
try:
async with aclosing(cls._run(req, user, streaming=True)) as events:
async for event, data in events:
yield _sse(event, data)
except asyncio.CancelledError:
return
except HTTPException as exc:
detail = exc.detail
content = detail if isinstance(detail, str) else str(detail)
if not content.strip():
content = f"请求失败({exc.status_code})"
new_messages.append({"role": "assistant", "content": content})
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_mcp_calls"] = [item.model_dump() for item in pending]
yield _sse("done", payload)
except Exception as exc: # noqa: BLE001
new_messages.append({"role": "assistant", "content": f"服务端异常: {_format_exc(exc)}"})
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_mcp_calls"] = [item.model_dump() for item in pending]
yield _sse("done", payload)
+8 -3
View File
@@ -121,6 +121,10 @@ def _build_error_payload(code: str, message: str, detail: Any = None) -> Dict[st
return payload
def tool_error(code: str, message: str | None = None) -> Dict[str, Any]:
return _build_error_payload(code, message or code)
def _normalize_tool_result(result: Any) -> Dict[str, Any]:
if isinstance(result, dict) and "ok" in result:
payload = dict(result)
@@ -144,7 +148,8 @@ def _normalize_tool_result(result: Any) -> Dict[str, Any]:
if isinstance(result, dict) and "error" in result:
error = result.get("error")
message = _stringify_value(error.get("message") if isinstance(error, dict) else error)
return _build_error_payload("error", message, detail=error)
code = str(error.get("code") or "execution_failed") if isinstance(error, dict) else "execution_failed"
return _build_error_payload(code, message)
view = _build_view(result)
summary = _build_summary(view)
@@ -166,8 +171,8 @@ def tool_result_to_content(result: Any) -> str:
def tool_spec_to_mcp_descriptor(spec: ToolSpec) -> McpToolDescriptor:
read_only = not spec.requires_confirmation
annotations: Dict[str, Any] = {
"readOnlyHint": read_only,
"destructiveHint": bool(spec.requires_confirmation),
"readOnlyHint": read_only and spec.name != "web_fetch",
"destructiveHint": bool(spec.requires_confirmation) or spec.name == "web_fetch",
}
if spec.name == "web_fetch":
annotations["openWorldHint"] = True
+4 -1
View File
@@ -4,6 +4,8 @@ from domain.processors import ProcessDirectoryRequest, ProcessRequest, Processor
from domain.virtual_fs import VirtualFSService
from .base import ToolSpec
from domain.permission.execution import require_path
from domain.permission.types import PathAction
async def _processors_list(_: Dict[str, Any]) -> Dict[str, Any]:
@@ -12,6 +14,7 @@ async def _processors_list(_: Dict[str, Any]) -> Dict[str, Any]:
async def _processors_run(args: Dict[str, Any]) -> Dict[str, Any]:
path = str(args.get("path") or "")
path = await require_path(path, PathAction.READ)
processor_type = str(args.get("processor_type") or "")
config = args.get("config")
if not isinstance(config, dict):
@@ -85,7 +88,7 @@ TOOLS: Dict[str, ToolSpec] = {
"config": {"type": "object", "description": "处理器配置,按 processors_list 返回的 config_schema 填写"},
"overwrite": {"type": "boolean", "description": "是否覆盖原文件/目录内文件"},
"save_to": {"type": "string", "description": "保存到指定路径(仅文件模式,且 overwrite=false 时使用)"},
"max_depth": {"type": "integer", "description": "目录遍历深度(仅目录模式)"},
"max_depth": {"type": "integer", "minimum": 0, "description": "目录遍历深度(仅目录模式)"},
"suffix": {"type": "string", "description": "目录批处理时的输出后缀(仅 produces_file 且 overwrite=false)"},
},
"required": ["path", "processor_type"],
+34 -13
View File
@@ -3,7 +3,9 @@ from typing import Any, Dict, Optional
from domain.virtual_fs import VirtualFSService
from domain.virtual_fs.search import VirtualFSSearchService
from .base import ToolSpec
from .base import ToolSpec, tool_error
from domain.permission.execution import current_user_id, require_path, require_paths
from domain.permission.types import PathAction
def _normalize_vfs_path(value: Any) -> str:
@@ -25,20 +27,25 @@ def _require_vfs_path(value: Any, field: str) -> str:
async def _vfs_list_dir(args: Dict[str, Any]) -> Dict[str, Any]:
path = _normalize_vfs_path(args.get("path") or "/") or "/"
path = await require_path(path, PathAction.READ)
page = int(args.get("page") or 1)
page_size = int(args.get("page_size") or 50)
sort_by = str(args.get("sort_by") or "name")
sort_order = str(args.get("sort_order") or "asc")
return await VirtualFSService.list_directory(path, page, page_size, sort_by, sort_order)
return await VirtualFSService.list_directory_with_permission(
path, current_user_id(), page, page_size, sort_by, sort_order, args.get("cursor")
)
async def _vfs_stat(args: Dict[str, Any]) -> Any:
path = _require_vfs_path(args.get("path"), "path")
path = await require_path(path, PathAction.READ)
return await VirtualFSService.stat(path)
async def _vfs_read_text(args: Dict[str, Any]) -> Dict[str, Any]:
path = _require_vfs_path(args.get("path"), "path")
path = await require_path(path, PathAction.READ)
encoding = str(args.get("encoding") or "utf-8")
max_chars = int(args.get("max_chars") or 8000)
@@ -47,7 +54,7 @@ async def _vfs_read_text(args: Dict[str, Any]) -> Dict[str, Any]:
try:
text = bytes(data).decode(encoding)
except UnicodeDecodeError:
return {"error": "binary_or_invalid_text", "path": path}
return tool_error("unsupported_capability", "binary_or_invalid_text")
elif isinstance(data, str):
text = data
else:
@@ -70,6 +77,7 @@ async def _vfs_write_text(args: Dict[str, Any]) -> Dict[str, Any]:
path = _require_vfs_path(args.get("path"), "path")
if path == "/":
raise ValueError("invalid_path")
path = await require_path(path, PathAction.WRITE)
encoding = str(args.get("encoding") or "utf-8")
content = str(args.get("content") or "")
data = content.encode(encoding)
@@ -79,11 +87,13 @@ async def _vfs_write_text(args: Dict[str, Any]) -> Dict[str, Any]:
async def _vfs_mkdir(args: Dict[str, Any]) -> Dict[str, Any]:
path = _require_vfs_path(args.get("path"), "path")
path = await require_path(path, PathAction.WRITE)
return await VirtualFSService.mkdir(path)
async def _vfs_delete(args: Dict[str, Any]) -> Dict[str, Any]:
path = _require_vfs_path(args.get("path"), "path")
path = await require_path(path, PathAction.DELETE)
return await VirtualFSService.delete(path)
@@ -92,6 +102,7 @@ async def _vfs_move(args: Dict[str, Any]) -> Dict[str, Any]:
dst = _require_vfs_path(args.get("dst"), "dst")
if src == "/" or dst == "/":
raise ValueError("invalid_path")
await require_paths([(src, PathAction.READ), (src, PathAction.DELETE), (dst, PathAction.WRITE)])
overwrite = bool(args.get("overwrite") or False)
return await VirtualFSService.move(src, dst, overwrite)
@@ -101,6 +112,7 @@ async def _vfs_copy(args: Dict[str, Any]) -> Dict[str, Any]:
dst = _require_vfs_path(args.get("dst"), "dst")
if src == "/" or dst == "/":
raise ValueError("invalid_path")
await require_paths([(src, PathAction.READ), (dst, PathAction.WRITE)])
overwrite = bool(args.get("overwrite") or False)
return await VirtualFSService.copy(src, dst, overwrite)
@@ -110,6 +122,7 @@ async def _vfs_rename(args: Dict[str, Any]) -> Dict[str, Any]:
dst = _require_vfs_path(args.get("dst"), "dst")
if src == "/" or dst == "/":
raise ValueError("invalid_path")
await require_paths([(src, PathAction.READ), (src, PathAction.DELETE), (dst, PathAction.WRITE)])
overwrite = bool(args.get("overwrite") or False)
return await VirtualFSService.rename(src, dst, overwrite)
@@ -122,7 +135,14 @@ async def _vfs_search(args: Dict[str, Any]) -> Dict[str, Any]:
top_k = int(args.get("top_k") or 10)
page = int(args.get("page") or 1)
page_size = int(args.get("page_size") or 10)
return await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
result = await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
from domain.permission.service import PermissionService
allowed = await PermissionService.filter_paths_by_permission(
current_user_id(), [str(item.path) for item in result.get("items", [])], PathAction.READ
)
allowed_set = set(allowed)
result["items"] = [item for item in result.get("items", []) if str(item.path) in allowed_set]
return result
TOOLS: Dict[str, ToolSpec] = {
@@ -133,10 +153,11 @@ TOOLS: Dict[str, ToolSpec] = {
"type": "object",
"properties": {
"path": {"type": "string", "description": "目录路径(绝对路径,如 /foo/bar)"},
"page": {"type": "integer", "description": "页码(从 1 开始)"},
"page_size": {"type": "integer", "description": "每页条数"},
"sort_by": {"type": "string", "description": "排序字段:name/size/mtime"},
"sort_order": {"type": "string", "description": "排序顺序:asc/desc"},
"page": {"type": "integer", "minimum": 1, "description": "页码(从 1 开始)"},
"page_size": {"type": "integer", "minimum": 1, "maximum": 100, "description": "每页条数"},
"sort_by": {"type": "string", "enum": ["name", "size", "mtime"], "description": "排序字段:name/size/mtime"},
"sort_order": {"type": "string", "enum": ["asc", "desc"], "description": "排序顺序:asc/desc"},
"cursor": {"type": "string", "description": "游标分页的下一页游标"},
},
"required": ["path"],
"additionalProperties": False,
@@ -166,7 +187,7 @@ TOOLS: Dict[str, ToolSpec] = {
"properties": {
"path": {"type": "string", "description": "文件路径(绝对路径,如 /foo/bar.md)"},
"encoding": {"type": "string", "description": "文本编码(默认 utf-8)"},
"max_chars": {"type": "integer", "description": "最多返回的字符数(默认 8000)"},
"max_chars": {"type": "integer", "minimum": 1, "maximum": 100000, "description": "最多返回的字符数(默认 8000)"},
},
"required": ["path"],
"additionalProperties": False,
@@ -273,10 +294,10 @@ TOOLS: Dict[str, ToolSpec] = {
"type": "object",
"properties": {
"q": {"type": "string", "description": "搜索关键词"},
"mode": {"type": "string", "description": "搜索模式:vector/filename(默认 vector)"},
"top_k": {"type": "integer", "description": "返回数量(vector 模式使用,默认 10)"},
"page": {"type": "integer", "description": "页码(filename 模式使用,默认 1)"},
"page_size": {"type": "integer", "description": "分页大小(filename 模式使用,默认 10)"},
"mode": {"type": "string", "enum": ["vector", "filename"], "description": "搜索模式:vector/filename(默认 vector)"},
"top_k": {"type": "integer", "minimum": 1, "maximum": 100, "description": "返回数量(vector 模式使用,默认 10)"},
"page": {"type": "integer", "minimum": 1, "description": "页码(filename 模式使用,默认 1)"},
"page_size": {"type": "integer", "minimum": 1, "maximum": 100, "description": "分页大小(filename 模式使用,默认 10)"},
},
"required": ["q"],
"additionalProperties": False,
+13 -3
View File
@@ -5,6 +5,7 @@ from urllib.parse import urljoin
import httpx
from .base import ToolSpec
from domain.permission.execution import ExecutionError
class _HtmlTextExtractor(HTMLParser):
@@ -99,10 +100,19 @@ async def _web_fetch(args: Dict[str, Any]) -> Dict[str, Any]:
request_kwargs["content"] = str(body)
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
resp = await client.request(method, url, **request_kwargs)
async with client.stream(method, url, **request_kwargs) as resp:
chunks = []
size = 0
async for chunk in resp.aiter_bytes():
size += len(chunk)
if size > 2 * 1024 * 1024:
raise ExecutionError("response_too_large")
chunks.append(chunk)
await resp.aclose()
body_bytes = b"".join(chunks)
content_type = resp.headers.get("content-type") or ""
text = resp.text or ""
text = body_bytes.decode(resp.encoding or "utf-8", errors="replace")
is_html = "html" in content_type.lower()
if not is_html:
probe = text.lstrip()[:200].lower()
@@ -167,7 +177,7 @@ TOOLS: Dict[str, ToolSpec] = {
"type": "object",
"properties": {
"url": {"type": "string", "description": "目标 URL"},
"method": {"type": "string", "description": "请求方法(默认 GET)"},
"method": {"type": "string", "description": "请求方法(默认 GET,所有方法免审批)"},
"headers": {"type": "object", "description": "请求头", "additionalProperties": {"type": "string"}},
"params": {"type": "object", "description": "查询参数", "additionalProperties": {"type": "string"}},
"json": {"type": "object", "description": "JSON 请求体"},
+1
View File
@@ -10,6 +10,7 @@ class AgentChatContext(BaseModel):
class AgentChatRequest(BaseModel):
messages: List[Dict[str, Any]] = Field(default_factory=list)
auto_execute: bool = False
approval_batch_id: Optional[str] = None
approved_mcp_call_ids: List[str] = Field(default_factory=list)
rejected_mcp_call_ids: List[str] = Field(default_factory=list)
context: Optional[AgentChatContext] = None
+2 -2
View File
@@ -92,7 +92,7 @@ class ConfigService:
if current_time - cache["timestamp"] < 3600 and cache["data"]:
return cache["data"]
try:
async with httpx.AsyncClient(timeout=10.0) as client:
async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client:
resp = await client.get(
"https://api.github.com/repos/DrizzleTime/Foxel/releases/latest",
follow_redirects=True,
@@ -106,7 +106,7 @@ class ConfigService:
cache["timestamp"] = current_time
cache["data"] = version_info
return version_info
except httpx.RequestError:
except httpx.HTTPError:
if cache["data"]:
return cache["data"]
return LatestVersionInfo()
+3 -1
View File
@@ -113,7 +113,9 @@ class NoticeService:
async def _fetch_remote_notices(cls) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
page = 1
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
# The notice endpoint is public and should not inherit malformed local
# proxy variables (for example, ``ALL_PROXY=socks://...``).
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, trust_env=False) as client:
while True:
resp = await client.get(
REMOTE_NOTICES_URL,
+93
View File
@@ -0,0 +1,93 @@
"""Request-local authority propagated only to MCP-originated background tasks."""
from contextlib import contextmanager
from contextvars import ContextVar
from copy import deepcopy
from typing import Any
from .service import PermissionService
from models.database import UserAccount
class ExecutionError(Exception):
def __init__(self, code: str):
self.code = code
super().__init__(code)
_scope: ContextVar[dict[str, Any] | None] = ContextVar("foxel_execution_scope", default=None)
def normalize_path(value: str) -> str:
if not isinstance(value, str) or not value.strip() or "\x00" in value:
raise ExecutionError("invalid_arguments")
parts = value.strip().replace("\\", "/").split("/")
if any(part in {".", ".."} for part in parts):
raise ExecutionError("invalid_arguments")
return "/" + "/".join(part for part in parts if part)
@contextmanager
def execution_context(scope: dict[str, Any] | None):
token = _scope.set(scope)
try:
yield
finally:
_scope.reset(token)
def execution_scope() -> dict[str, Any] | None:
return deepcopy(_scope.get())
def current_user_id() -> int:
scope = _scope.get()
if scope is None:
raise ExecutionError("permission_denied")
return scope["user_id"]
async def require_path(path: str, action: str) -> str:
path = normalize_path(path)
await require_paths([(path, action)])
return path
async def require_paths(paths: list[tuple[str, str]]) -> None:
user_id = current_user_id()
if not await UserAccount.filter(id=user_id, disabled=False).exists():
raise ExecutionError("permission_denied")
PermissionService.clear_cache(user_id)
for path, action in paths:
if not await PermissionService.check_path_permission(user_id, normalize_path(path), action):
raise ExecutionError("permission_denied")
async def guard_path(path: str, action: str) -> None:
scope = _scope.get()
if scope is None:
return
path = normalize_path(path)
permissions = scope.get("permissions")
if permissions is not None and [path, action] not in permissions:
raise ExecutionError("permission_scope_changed")
await validate_scope()
await require_path(path, action)
async def validate_scope() -> None:
scope = _scope.get()
if scope is None or "permissions" not in scope:
return
await require_paths([tuple(item) for item in scope["permissions"]])
from domain.agent.execution import snapshot_tree
for root, expected in scope.get("trees", {}).items():
actual = await snapshot_tree(root)
if actual != expected:
raise ExecutionError("permission_scope_changed")
def forget_tree(root: str) -> None:
scope = _scope.get()
if scope is not None:
scope.get("trees", {}).pop(normalize_path(root), None)
+33
View File
@@ -17,6 +17,8 @@ from .types import (
ProcessRequest,
UpdateSourceRequest,
)
from domain.permission.execution import guard_path, validate_scope, execution_scope
from domain.permission.types import PathAction
class ProcessorService:
@@ -91,6 +93,7 @@ class ProcessorService:
else:
overwrite = False
suffix = None
payload = {
"path": req.path,
"processor_type": req.processor_type,
@@ -104,6 +107,7 @@ class ProcessorService:
@classmethod
async def scan_directory(cls, req: ProcessDirectoryRequest):
await validate_scope()
if req.max_depth is not None and req.max_depth < 0:
raise HTTPException(400, detail="max_depth must be >= 0")
@@ -130,6 +134,34 @@ class ProcessorService:
overwrite = False
suffix = None
scope = execution_scope()
if scope is not None:
snapshot = scope["trees"].get(req.path)
if snapshot is None:
raise HTTPException(400, detail="permission_scope_unverifiable")
supported = {str(ext).lower().lstrip(".") for ext in schema.get("supported_exts", [])}
scheduled = 0
for source, is_dir in snapshot.items():
if is_dir:
continue
relative = Path(source).relative_to(req.path)
if req.max_depth is not None and len(relative.parts) - 1 > req.max_depth:
continue
extension = Path(source).suffix.lower().lstrip(".")
if supported and extension not in supported:
continue
await guard_path(source, PathAction.READ)
output = None
if produces_file and not overwrite:
p = Path(source)
output = str(p.with_name(p.stem + suffix + p.suffix))
await task_queue_service.add_task("process_file", {
"path": source, "processor_type": req.processor_type, "config": req.config,
"save_to": output, "overwrite": overwrite,
})
scheduled += 1
return {"scheduled": scheduled}
supported_exts = schema.get("supported_exts") or []
allowed_exts = {
ext.lower().lstrip('.')
@@ -196,6 +228,7 @@ class ProcessorService:
if not matches_extension(child_rel):
continue
absolute_path = build_absolute_path(adapter_model.path, child_rel)
await guard_path(absolute_path, PathAction.READ)
save_to = None
if produces_file and not overwrite and suffix:
save_to = apply_suffix(absolute_path, suffix)
+15
View File
@@ -3,6 +3,7 @@ from typing import Dict, Any
from pydantic import BaseModel, Field
import uuid
from enum import Enum
from domain.permission.execution import execution_context, execution_scope, validate_scope
class TaskStatus(str, Enum):
@@ -43,6 +44,14 @@ class TaskQueueService:
self._worker_seq: int = 0
async def add_task(self, name: str, task_info: Dict[str, Any]) -> Task:
scope = execution_scope()
if scope is not None:
if name == "process_file" and any(
tree.get(task_info.get("path")) is False for tree in scope.get("trees", {}).values()
):
path = task_info["path"]
scope["trees"] = {path: {path: False}}
task_info = {**task_info, "_execution_scope": scope}
task = Task(name=name, task_info=task_info)
self._tasks[task.id] = task
await self._queue.put(task)
@@ -70,9 +79,15 @@ class TaskQueueService:
task.meta = (task.meta or {}) | meta
async def _execute_task(self, task: Task):
scope = task.task_info.get("_execution_scope")
with execution_context(scope):
await self._execute_scoped_task(task)
async def _execute_scoped_task(self, task: Task):
task.status = TaskStatus.RUNNING
try:
await validate_scope()
# Local import to avoid circular dependency during module load.
from domain.virtual_fs import VirtualFSService
+9
View File
@@ -5,6 +5,8 @@ from fastapi import HTTPException
from fastapi.responses import Response
from domain.tasks import TaskService
from domain.permission.execution import guard_path, validate_scope, forget_tree
from domain.permission.types import PathAction
from .thumbnail import is_raw_filename, raw_bytes_to_jpeg
from .listing import VirtualFSListingMixin
@@ -36,6 +38,7 @@ class VirtualFSFileOpsMixin(VirtualFSListingMixin):
@classmethod
async def read_file(cls, path: str) -> Union[bytes, Any]:
await guard_path(path, PathAction.READ)
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
if rel.endswith("/") or rel == "":
raise HTTPException(400, detail="Path is a directory")
@@ -44,6 +47,7 @@ class VirtualFSFileOpsMixin(VirtualFSListingMixin):
@classmethod
async def write_file(cls, path: str, data: bytes):
await guard_path(path, PathAction.WRITE)
adapter_instance, adapter_model, root, rel = await cls.resolve_adapter_and_rel(path)
if rel.endswith("/"):
raise HTTPException(400, detail="Invalid file path")
@@ -55,6 +59,7 @@ class VirtualFSFileOpsMixin(VirtualFSListingMixin):
@classmethod
async def write_file_stream(cls, path: str, data_iter: AsyncIterator[bytes], overwrite: bool = True):
await guard_path(path, PathAction.WRITE)
adapter_instance, adapter_model, root, rel = await cls.resolve_adapter_and_rel(path)
if rel.endswith("/"):
raise HTTPException(400, detail="Invalid file path")
@@ -91,6 +96,7 @@ class VirtualFSFileOpsMixin(VirtualFSListingMixin):
@classmethod
async def make_dir(cls, path: str):
await guard_path(path, PathAction.WRITE)
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
if not rel:
return
@@ -99,11 +105,14 @@ class VirtualFSFileOpsMixin(VirtualFSListingMixin):
@classmethod
async def delete_path(cls, path: str):
await validate_scope()
await guard_path(path, PathAction.DELETE)
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
if not rel:
raise HTTPException(400, detail="Cannot delete root")
delete_func = await cls._ensure_method(adapter_instance, "delete")
await delete_func(root, rel)
forget_tree(path)
await TaskService.trigger_tasks("file_deleted", path)
@classmethod
+5
View File
@@ -4,6 +4,8 @@ from fastapi import HTTPException
from fastapi.responses import Response
from .transfer import VirtualFSTransferMixin
from domain.permission.execution import guard_path
from domain.permission.types import PathAction
class VirtualFSProcessingMixin(VirtualFSTransferMixin):
@@ -18,6 +20,8 @@ class VirtualFSProcessingMixin(VirtualFSTransferMixin):
) -> Any:
from domain.processors import get_processor
await guard_path(path, PathAction.READ)
processor = get_processor(processor_type)
if not processor:
raise HTTPException(400, detail=f"Processor {processor_type} not found")
@@ -81,6 +85,7 @@ class VirtualFSProcessingMixin(VirtualFSTransferMixin):
if not matches_extension(child_rel):
continue
absolute_path = cls._build_absolute_path(adapter_model.path, child_rel)
await guard_path(absolute_path, PathAction.READ)
data = b""
if requires_input_bytes:
data = await cls.read_file(absolute_path)
+11
View File
@@ -6,6 +6,8 @@ import aiofiles
from fastapi import HTTPException
from .file_ops import VirtualFSFileOpsMixin
from domain.permission.execution import guard_path, validate_scope
from domain.permission.types import PathAction
class VirtualFSTransferMixin(VirtualFSFileOpsMixin):
@@ -13,6 +15,7 @@ class VirtualFSTransferMixin(VirtualFSFileOpsMixin):
async def move_path(
cls, src: str, dst: str, overwrite: bool = False, return_debug: bool = True, allow_cross: bool = False
):
await validate_scope()
adapter_s, adapter_model_s, root_s, rel_s = await cls.resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await cls.resolve_adapter_and_rel(dst)
debug_info = {
@@ -95,6 +98,7 @@ class VirtualFSTransferMixin(VirtualFSFileOpsMixin):
@classmethod
async def rename_path(cls, src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
await validate_scope()
adapter_s, adapter_model_s, root_s, rel_s = await cls.resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await cls.resolve_adapter_and_rel(dst)
debug_info = {
@@ -167,6 +171,7 @@ class VirtualFSTransferMixin(VirtualFSFileOpsMixin):
async def copy_path(
cls, src: str, dst: str, overwrite: bool = False, return_debug: bool = True, allow_cross: bool = False
):
await validate_scope()
adapter_s, adapter_model_s, root_s, rel_s = await cls.resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await cls.resolve_adapter_and_rel(dst)
debug_info = {
@@ -461,9 +466,15 @@ class VirtualFSTransferMixin(VirtualFSFileOpsMixin):
async def ensure_dir(rel_path: str):
if not rel_path or rel_path in ensured_dirs:
return
absolute = cls._build_absolute_path(adapter_model_d.path, rel_path)
exists = getattr(adapter_d, "exists", None)
if callable(exists) and await exists(root_d, rel_path):
ensured_dirs.add(rel_path)
return
parent = cls._parent_rel(rel_path)
if parent:
await ensure_dir(parent)
await guard_path(absolute, PathAction.WRITE)
try:
await mkdir_func(root_d, rel_path)
except FileExistsError:
+35
View File
@@ -180,6 +180,41 @@ class AIDefaultModel(Model):
table = "ai_default_models"
class AgentApprovalBatch(Model):
id = fields.CharField(max_length=36, pk=True)
user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField(
"models.UserAccount", related_name="agent_approvals", on_delete=fields.CASCADE
)
assistant_message = fields.JSONField()
continuation_status = fields.CharField(max_length=20, default="pending")
continuation_result = fields.JSONField(null=True)
executing = fields.BooleanField(default=False)
expires_at = fields.DatetimeField()
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "agent_approval_batches"
class AgentApprovalCall(Model):
id = fields.IntField(pk=True)
batch: fields.ForeignKeyRelation[AgentApprovalBatch] = fields.ForeignKeyField(
"models.AgentApprovalBatch", related_name="calls", on_delete=fields.CASCADE
)
call_id = fields.CharField(max_length=255)
position = fields.IntField()
name = fields.CharField(max_length=100)
arguments = fields.JSONField()
requires_confirmation = fields.BooleanField(default=True)
status = fields.CharField(max_length=20, default="pending")
result = fields.TextField(null=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "agent_approval_calls"
unique_together = (("batch", "call_id"),)
class AutomationTask(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=100)
+488
View File
@@ -0,0 +1,488 @@
import asyncio
import json
import unittest
import tempfile
from pathlib import Path
from contextlib import asynccontextmanager
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI, HTTPException
from tortoise import Tortoise
from api.response import cursor_page, page
from domain.agent.api import router
from domain.agent.approvals import create_batch, execute_call, load_batch, now
from domain.agent.execution import execute_tool, snapshot_tree, validate_arguments
from domain.agent.mcp import MCP_HTTP_APP, mcp_client_session
from domain.agent.service import AgentService, _choose_chat_ability
from domain.agent.tools import mcp_tool_descriptors
from domain.agent.tools.base import tool_error, tool_result_to_content
from domain.agent.types import AgentChatRequest
from domain.ai import MissingModelError
from domain.auth import User, get_current_active_user
from domain.permission.execution import ExecutionError, execution_context, guard_path, validate_scope
from domain.permission.service import PermissionService
from domain.tasks.task_queue import TaskQueueService, TaskStatus
from domain.virtual_fs import VirtualFSService
from models.database import AgentApprovalBatch, AgentApprovalCall, PathRule, Role, StorageAdapter, UserAccount, UserRole
class DatabaseTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
await Tortoise.init(db_url="sqlite://:memory:", modules={"models": ["models.database"]}, _enable_global_fallback=True)
await Tortoise.generate_schemas()
self.account = await UserAccount.create(username="member", hashed_password="unused")
self.user = User(id=self.account.id, username="member")
self.admin_account = await UserAccount.create(username="admin", hashed_password="unused", is_admin=True)
self.admin = User(id=self.admin_account.id, username="admin", is_admin=True)
self.role = await Role.create(name="member")
await UserRole.create(user_id=self.user.id, role_id=self.role.id)
await PathRule.create(role_id=self.role.id, path_pattern="/allowed/**", can_read=True, can_write=True, can_delete=True)
await PathRule.create(role_id=self.role.id, path_pattern="/allowed/secret/**", can_read=False, can_write=False, can_delete=False, priority=10)
PermissionService.clear_cache()
async def asyncTearDown(self):
PermissionService.clear_cache()
await Tortoise.close_connections()
def assert_error(self, result, code):
self.assertFalse(result["ok"], result)
self.assertEqual(result["error"]["code"], code, result)
class BoundaryTests(DatabaseTests):
async def test_real_processor_scan_uses_snapshot_and_child_scopes(self):
from domain.adapters import runtime_registry
from domain.adapters.providers.local import LocalAdapter
from domain.processors import TYPE_MAP, ProcessorService
from domain.tasks import task_queue_service
processor = SimpleNamespace(produces_file=True, supported_exts=["jpg"], supports_directory=False, requires_input_bytes=True,
process=AsyncMock(return_value=b"processed"))
with tempfile.TemporaryDirectory() as temporary:
mount = await StorageAdapter.create(name="local", type="local", path="/allowed", config={"root": temporary})
adapter = LocalAdapter(mount)
root = Path(temporary)
(root / "photos").mkdir()
(root / "photos/a.jpg").write_bytes(b"a")
(root / "photos/b.jpg").write_bytes(b"b")
queue = TaskQueueService()
with patch.object(runtime_registry, "get", return_value=adapter), patch.dict(TYPE_MAP, {"image_watermark": lambda: processor}), patch.object(task_queue_service, "add_task", new=queue.add_task):
result = await execute_tool("processors_run", {"path": "/allowed/photos", "processor_type": "image_watermark", "config": {"text": "x"}, "max_depth": 0, "overwrite": False, "suffix": "_out"}, self.user)
self.assertTrue(result["ok"], result)
scan = queue.get_task(result["data"]["task_id"])
await queue._execute_task(scan)
self.assertEqual(scan.status, TaskStatus.SUCCESS, scan.error)
children = [task for task in queue.get_all_tasks() if task.name == "process_file"]
self.assertEqual(len(children), 2)
for child in children:
self.assertEqual(list(child.task_info["_execution_scope"]["trees"]), [child.task_info["path"]])
await queue._execute_task(child)
self.assertEqual(child.status, TaskStatus.SUCCESS, child.error)
self.assertEqual((root / "photos/a_out.jpg").read_bytes(), b"processed")
self.assertEqual((root / "photos/b_out.jpg").read_bytes(), b"processed")
async def test_real_local_recursive_delete_and_copy(self):
from domain.adapters import runtime_registry
from domain.adapters.providers.local import LocalAdapter
with tempfile.TemporaryDirectory() as temporary:
mount = await StorageAdapter.create(name="local", type="local", path="/allowed", config={"root": temporary})
adapter = LocalAdapter(mount)
with patch.object(runtime_registry, "get", return_value=adapter):
root = Path(temporary)
(root / "source/sub").mkdir(parents=True)
(root / "source/sub/a.txt").write_text("hello")
result = await execute_tool("vfs_copy", {"src": "/allowed/source", "dst": "/allowed/copied"}, self.user)
self.assertTrue(result["ok"], result)
self.assertEqual((root / "copied/sub/a.txt").read_text(), "hello")
result = await execute_tool("vfs_delete", {"path": "/allowed/copied"}, self.user)
self.assertTrue(result["ok"], result)
self.assertFalse((root / "copied").exists())
(root / "source/secret").mkdir()
(root / "source/secret/a.txt").write_text("private")
await PathRule.create(role_id=self.role.id, path_pattern="/allowed/source/secret/**", can_read=False, can_write=False, can_delete=False, priority=20)
denied = await execute_tool("vfs_delete", {"path": "/allowed/source"}, self.user)
self.assert_error(denied, "permission_denied")
self.assertTrue((root / "source/sub/a.txt").exists())
async def test_real_cross_mount_task_preserves_scope(self):
from domain.adapters import runtime_registry
from domain.adapters.providers.local import LocalAdapter
from domain.tasks import task_queue_service
with tempfile.TemporaryDirectory() as left, tempfile.TemporaryDirectory() as right, tempfile.TemporaryDirectory() as staging:
source_mount = await StorageAdapter.create(name="source", type="local", path="/allowed/source", config={"root": left})
target_mount = await StorageAdapter.create(name="target", type="local", path="/allowed/target", config={"root": right})
source_adapter, target_adapter = LocalAdapter(source_mount), LocalAdapter(target_mount)
(Path(left) / "folder").mkdir()
(Path(left) / "folder/a.txt").write_text("cross mount")
(Path(right) / "out").mkdir()
(Path(right) / "out/old.txt").write_text("old")
queue = TaskQueueService()
instances = {source_mount.id: source_adapter, target_mount.id: target_adapter}
with patch.object(runtime_registry, "get", side_effect=instances.get), patch.object(task_queue_service, "add_task", new=queue.add_task), patch.object(VirtualFSService, "CROSS_TRANSFER_TEMP_ROOT", Path(staging)):
result = await execute_tool("vfs_move", {"src": "/allowed/source/folder", "dst": "/allowed/target/out", "overwrite": True}, self.user)
self.assertTrue(result["ok"], result)
task = queue.get_task(result["data"]["task_id"])
self.assertEqual(task.task_info["_execution_scope"]["user_id"], self.user.id)
await queue._execute_task(task)
self.assertEqual(task.status, TaskStatus.SUCCESS, task.error)
self.assertEqual((Path(right) / "out/a.txt").read_text(), "cross mount")
self.assertFalse((Path(right) / "out/old.txt").exists())
self.assertFalse((Path(left) / "folder").exists())
async def test_all_file_tools_deny_before_storage_access(self):
cases = [
("vfs_list_dir", {"path": "/private"}),
("vfs_stat", {"path": "/private"}),
("vfs_read_text", {"path": "/private"}),
("vfs_write_text", {"path": "/private", "content": "x"}),
("vfs_mkdir", {"path": "/private"}),
("vfs_delete", {"path": "/private"}),
("vfs_copy", {"src": "/private", "dst": "/allowed/out"}),
("vfs_move", {"src": "/private", "dst": "/allowed/out"}),
("vfs_rename", {"src": "/private", "dst": "/allowed/out"}),
("processors_run", {"path": "/private", "processor_type": "image_watermark"}),
]
with patch.object(VirtualFSService, "stat", AsyncMock()) as stat:
for name, args in cases:
with self.subTest(tool=name):
self.assert_error(await execute_tool(name, args, self.user), "permission_denied")
stat.assert_not_awaited()
async def test_admin_can_write_and_paths_are_canonical(self):
with patch.object(VirtualFSService, "write_file", AsyncMock()) as written:
result = await execute_tool("vfs_write_text", {"path": "//allowed\\docs//a.txt/", "content": "hello"}, self.admin)
self.assertTrue(result["ok"], result)
written.assert_awaited_once_with("/allowed/docs/a.txt", b"hello")
async def test_no_identity_and_disabled_identity_denied(self):
self.assert_error(await execute_tool("time", {}, None), "permission_denied")
self.assert_error(await execute_tool("time", {}, self.user.model_copy(update={"disabled": True})), "permission_denied")
async def test_invalid_inputs_do_not_execute(self):
cases = [
("vfs_stat", {"path": "/allowed/../private"}),
("vfs_stat", {"path": "/allowed/./a"}),
("vfs_stat", {"path": "/allowed/\x00a"}),
("vfs_read_text", {"path": "/allowed/a", "max_chars": 0}),
("vfs_read_text", {"path": "/allowed/a", "max_chars": 100001}),
("vfs_read_text", {"path": "/allowed/a", "encoding": "bad-encoding"}),
("vfs_list_dir", {"path": "/allowed", "page_size": 101}),
("vfs_list_dir", {"path": "/allowed", "page": True}),
("vfs_list_dir", {"path": "/allowed", "sort_by": "invalid"}),
("vfs_search", {"q": "x", "mode": "unknown"}),
("vfs_search", {"q": "x", "top_k": 101}),
("processors_run", {"path": "/allowed", "processor_type": "x", "max_depth": -1}),
("web_fetch", {"url": "file:///etc/passwd"}),
("web_fetch", {"url": "https://example.com", "method": "TRACE"}),
("vfs_write_text", {"path": "/allowed/a", "content": "x", "user_id": self.admin.id}),
]
for name, args in cases:
with self.subTest(tool=name, args=args):
self.assert_error(await execute_tool(name, args, self.user), "invalid_arguments")
async def test_directory_listing_filters_children_and_preserves_cursor(self):
listing = cursor_page([{"name": "ok", "is_dir": False}, {"name": "secret", "is_dir": True}], 50, next_cursor="next")
with patch.object(VirtualFSService, "list_virtual_dir", AsyncMock(return_value=listing)) as listed:
result = await execute_tool("vfs_list_dir", {"path": "/allowed", "cursor": "start"}, self.user)
self.assertEqual([item["name"] for item in result["data"]["entries"]], ["ok"])
self.assertEqual(result["data"]["pagination"]["next_cursor"], "next")
self.assertEqual(listed.await_args.args[-1], "start")
async def test_search_filters_private_snippets(self):
from domain.virtual_fs.types import SearchResultItem
from domain.virtual_fs.search import VirtualFSSearchService
items = [SearchResultItem(id="1", path="/allowed/a", score=1, snippet="public"),
SearchResultItem(id="2", path="/private/a", score=1, snippet="private-secret")]
with patch.object(VirtualFSSearchService, "search", AsyncMock(return_value={"items": items})):
result = await execute_tool("vfs_search", {"q": "x"}, self.user)
self.assertEqual(result["data"]["items"][0]["path"], "/allowed/a")
self.assertNotIn("private-secret", json.dumps(result))
async def test_recursive_denial_has_zero_mutations(self):
tree = {"/allowed": True, "/allowed/ok": False, "/allowed/secret": True, "/allowed/secret/a": False}
with patch("domain.agent.execution.snapshot_tree", AsyncMock(return_value=tree)), patch.object(VirtualFSService, "delete", AsyncMock()) as deleted:
result = await execute_tool("vfs_delete", {"path": "/allowed"}, self.user)
self.assert_error(result, "permission_denied")
deleted.assert_not_awaited()
async def test_transfer_checks_source_read_delete_and_target_children(self):
source = {"/allowed/from": True, "/allowed/from/a": False}
with patch("domain.agent.execution.snapshot_tree", AsyncMock(return_value=source)), patch.object(VirtualFSService, "stat", AsyncMock(return_value={"is_dir": True})), patch.object(VirtualFSService, "copy", AsyncMock()) as copied:
result = await execute_tool("vfs_copy", {"src": "/allowed/from", "dst": "/allowed/secret/out"}, self.user)
self.assert_error(result, "permission_denied")
copied.assert_not_awaited()
async def test_snapshot_supports_cursor_and_rejects_repeated_cursor(self):
with patch.object(VirtualFSService, "stat", AsyncMock(return_value={"is_dir": True})), patch.object(VirtualFSService, "list_virtual_dir", AsyncMock(side_effect=[cursor_page([{"name": "a"}], 100, next_cursor="x"), cursor_page([{"name": "b"}], 100)])):
tree = await snapshot_tree("/allowed")
self.assertEqual(set(tree), {"/allowed", "/allowed/a", "/allowed/b"})
with patch.object(VirtualFSService, "stat", AsyncMock(return_value={"is_dir": True})), patch.object(VirtualFSService, "list_virtual_dir", AsyncMock(return_value=cursor_page([], 100, next_cursor="x"))):
with self.assertRaisesRegex(ExecutionError, "permission_scope_unverifiable"):
await snapshot_tree("/allowed")
async def test_snapshot_unknown_total_fails_closed(self):
with patch.object(VirtualFSService, "stat", AsyncMock(return_value={"is_dir": True})), patch.object(VirtualFSService, "list_virtual_dir", AsyncMock(return_value={"items": [], "total": None})):
with self.assertRaisesRegex(ExecutionError, "permission_scope_unverifiable"):
await snapshot_tree("/allowed")
async def test_new_child_and_permission_revocation_stop_task(self):
scope = {"user_id": self.user.id, "permissions": [["/allowed", "read"]], "trees": {"/allowed": {"/allowed": True}}}
with execution_context(scope), patch("domain.agent.execution.snapshot_tree", AsyncMock(return_value={"/allowed": True, "/allowed/new": False})):
with self.assertRaisesRegex(ExecutionError, "permission_scope_changed"):
await validate_scope()
await UserAccount.filter(id=self.user.id).update(disabled=True)
with execution_context({"user_id": self.user.id, "permissions": [["/allowed", "write"]]}):
with self.assertRaisesRegex(ExecutionError, "permission_denied"):
await guard_path("/allowed", "write")
async def test_background_scope_and_child_identity_are_preserved(self):
queue = TaskQueueService()
scope = {"user_id": self.user.id, "permissions": [["/allowed/a", "read"]], "trees": {"/allowed/a": {"/allowed/a": False}}}
with execution_context(scope):
task = await queue.add_task("process_file", {"path": "/allowed/a", "processor_type": "vector_index", "config": {}})
self.assertEqual(task.task_info["_execution_scope"]["user_id"], self.user.id)
scope["permissions"].clear()
self.assertTrue(task.task_info["_execution_scope"]["permissions"])
with patch("domain.agent.execution.snapshot_tree", AsyncMock(return_value={"/allowed/a": False})), patch.object(VirtualFSService, "process_file", AsyncMock()) as processed:
await UserAccount.filter(id=self.user.id).update(disabled=True)
await queue._execute_task(task)
self.assertEqual(task.status, TaskStatus.FAILED)
processed.assert_not_awaited()
async def test_processor_requires_output_write_and_valid_config(self):
with patch("domain.agent.execution.snapshot_tree", AsyncMock(return_value={"/allowed/a.jpg": False})), patch.object(VirtualFSService, "path_is_directory", AsyncMock(return_value=False)), patch("domain.agent.tools.processors.ProcessorService.process_file", AsyncMock()) as processed:
self.assert_error(await execute_tool("processors_run", {"path": "/allowed/a.jpg", "processor_type": "image_watermark", "config": {"text": "x"}, "save_to": "/private/out.jpg"}, self.user), "permission_denied")
self.assert_error(await execute_tool("processors_run", {"path": "/allowed/a.jpg", "processor_type": "image_watermark", "config": {}}, self.user), "invalid_arguments")
self.assert_error(await execute_tool("processors_run", {"path": "/allowed/a.jpg", "processor_type": "image_watermark", "config": {"text": "x"}}, self.user), "permission_scope_unverifiable")
processed.assert_not_awaited()
async def test_web_methods_remain_unconfirmed_with_accurate_annotations(self):
descriptor = next(d for d in mcp_tool_descriptors() if d.name == "web_fetch")
self.assertFalse(descriptor.requires_confirmation)
self.assertFalse(descriptor.annotations["readOnlyHint"])
self.assertTrue(descriptor.annotations["destructiveHint"])
self.assertTrue(descriptor.annotations["openWorldHint"])
transport = httpx.MockTransport(lambda req: httpx.Response(409, text="conflict", request=req))
original_client = httpx.AsyncClient
with patch("domain.agent.tools.web_fetch.httpx.AsyncClient", lambda **kw: original_client(transport=transport, **kw)):
for method in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]:
result = await execute_tool("web_fetch", {"url": "https://example.com", "method": method}, self.user)
self.assertTrue(result["ok"], result)
self.assertEqual(result["data"]["status_code"], 409)
async def test_web_response_limit_and_timeout(self):
original_client = httpx.AsyncClient
for handler, code in [(lambda req: httpx.Response(200, content=b"x" * (2 * 1024 * 1024 + 1)), "response_too_large"),
(lambda req: (_ for _ in ()).throw(httpx.ReadTimeout("secret")), "request_timeout")]:
with patch("domain.agent.tools.web_fetch.httpx.AsyncClient", lambda **kw: original_client(transport=httpx.MockTransport(handler), **kw)):
self.assert_error(await execute_tool("web_fetch", {"url": "https://example.com"}, self.user), code)
async def test_internal_exception_is_redacted(self):
with patch.object(VirtualFSService, "read_file", AsyncMock(side_effect=RuntimeError("secret password /etc/private"))):
result = await execute_tool("vfs_read_text", {"path": "/allowed/a"}, self.user)
self.assert_error(result, "execution_failed")
self.assertNotIn("password", json.dumps(result))
async def test_real_mcp_resources_auth_context_and_tools(self):
with patch("domain.agent.mcp.AuthService.create_access_token", AsyncMock(return_value="test-token")), patch("domain.agent.mcp.AuthService.get_current_user", AsyncMock(return_value=self.user)):
async with MCP_HTTP_APP.router.lifespan_context(MCP_HTTP_APP):
async with mcp_client_session(self.user, "//allowed//docs") as session:
tools = await session.list_tools()
self.assertEqual(len(tools.tools), 14)
read_tool = next(tool for tool in tools.tools if tool.name == "vfs_read_text")
bounds = read_tool.input_schema["properties"]["max_chars"]
self.assertIn("100000", json.dumps(bounds))
invalid = await session.call_tool("vfs_read_text", {"path": "/allowed/a", "max_chars": True})
self.assertTrue(invalid.is_error)
current = await session.read_resource("foxel://context/current-path")
self.assertEqual(json.loads(current.contents[0].text)["current_path"], "/allowed/docs")
denied = await session.call_tool("vfs_read_text", {"path": "/private"})
self.assert_error(json.loads(denied.content[0].text), "permission_denied")
denied_resource = await session.read_resource("foxel://vfs/text/private")
self.assert_error(json.loads(denied_resource.contents[0].text), "permission_denied")
class ApprovalTests(DatabaseTests):
async def asyncSetUp(self):
await super().asyncSetUp()
self.assistant = {"role": "assistant", "content": "", "mcp_calls": [
{"id": "one", "name": "vfs_write_text", "arguments": {"path": "/allowed/one", "content": "original"}},
{"id": "two", "name": "vfs_mkdir", "arguments": {"path": "/allowed/two"}},
]}
self.index = {d.name: {"meta": d.meta} for d in mcp_tool_descriptors()}
self.executed = []
async def call_tool(name, arguments):
self.executed.append((name, arguments))
return SimpleNamespace(content=[], structured_content={"ok": True, "data": arguments})
@asynccontextmanager
async def session(*args):
yield SimpleNamespace(call_tool=call_tool)
self.patches = [
patch("domain.agent.service.mcp_client_session", session),
patch("domain.agent.service._list_mcp_tools", AsyncMock(return_value=[{"name": k, **v} for k, v in self.index.items()])),
patch("domain.agent.service._choose_chat_ability", AsyncMock(return_value="tools")),
]
for p in self.patches:
p.start()
self.model = AsyncMock(return_value={"role": "assistant", "content": "done"})
self.model_patch = patch("domain.agent.service.chat_completion", self.model)
self.model_patch.start()
async def asyncTearDown(self):
self.model_patch.stop()
for p in reversed(self.patches):
p.stop()
await super().asyncTearDown()
async def batch(self):
return await create_batch(self.user.id, self.assistant, self.index)
def request(self, batch, approved=(), rejected=(), messages=None):
return AgentChatRequest(messages=messages or [self.assistant], approval_batch_id=batch.id,
approved_mcp_call_ids=list(approved), rejected_mcp_call_ids=list(rejected))
async def test_partial_approval_uses_saved_arguments_and_blocks_model(self):
batch = await self.batch()
tampered = json.loads(json.dumps(self.assistant))
tampered["mcp_calls"][0]["arguments"]["content"] = "tampered"
result = await AgentService.chat(self.request(batch, approved=["one"], messages=[tampered]), self.user)
self.assertEqual(self.executed[0][1]["content"], "original")
self.assertEqual([p["id"] for p in result["pending_mcp_calls"]], ["two"])
self.assertEqual(result["approval_batch_id"], batch.id)
self.assertTrue(result["replace_messages"])
self.model.assert_not_awaited()
final = await AgentService.chat(self.request(batch, rejected=["two"], messages=result["messages"]), self.user)
self.assertEqual(final["pending_mcp_calls"], [])
wire = self.model.await_args.args[0]
self.assertEqual([m["mcp_call_id"] for m in wire if m["role"] == "tool"], ["one", "two"])
self.assertEqual(len(self.executed), 1)
async def test_all_approvals_execute_in_original_order(self):
batch = await self.batch()
result = await AgentService.chat(self.request(batch, approved=["two", "one"]), self.user)
self.assertEqual([name for name, _ in self.executed], ["vfs_write_text", "vfs_mkdir"])
self.assertEqual(result["finish_reason"], "completed")
repeated = await AgentService.chat(self.request(batch, approved=["two", "one"]), self.user)
self.assertEqual(repeated, result)
self.assertEqual(len(self.executed), 2)
self.model.assert_awaited_once()
async def test_concurrent_batch_requests_do_not_reorder_operations(self):
batch = await self.batch()
started, release = asyncio.Event(), asyncio.Event()
async def execute(name, args):
self.executed.append((name, args))
started.set()
await release.wait()
return tool_result_to_content({"written": True})
async def execute_mcp(session, name, args):
return await execute(name, args)
with patch("domain.agent.service._execute_mcp_call", new=execute_mcp):
first = asyncio.create_task(AgentService.chat(self.request(batch, approved=["one", "two"]), self.user))
await asyncio.wait_for(started.wait(), timeout=3)
second = await AgentService.chat(self.request(batch, approved=["two"]), self.user)
self.assertEqual(second["finish_reason"], "operation_in_progress")
self.assertEqual(len(self.executed), 1)
release.set()
await first
self.assertEqual([name for name, _ in self.executed], ["vfs_write_text", "vfs_mkdir"])
async def test_unknown_cross_user_conflicting_and_legacy_ids_rejected(self):
batch = await self.batch()
for req, user in [(self.request(batch, approved=["unknown"]), self.user),
(self.request(batch, approved=["one"], rejected=["one"]), self.user),
(self.request(batch, approved=["one"]), self.admin),
(AgentChatRequest(messages=[self.assistant], approved_mcp_call_ids=["one"]), self.user)]:
with self.subTest(req=req):
with self.assertRaises(HTTPException) as error:
await AgentService.chat(req, user)
self.assertEqual(error.exception.status_code, 400)
self.assertFalse(self.executed)
async def test_expired_calls_require_regeneration(self):
batch = await self.batch()
await AgentApprovalBatch.filter(id=batch.id).update(expires_at=now() - timedelta(seconds=1))
with self.assertRaisesRegex(HTTPException, "approval_expired"):
await AgentService.chat(self.request(batch, approved=["one"]), self.user)
self.assertEqual(await AgentApprovalCall.filter(batch_id=batch.id, status="expired").count(), 2)
self.assertFalse(self.executed)
async def test_concurrent_claim_and_cancelled_write_never_retry(self):
batch = await self.batch()
record = await AgentApprovalCall.get(batch_id=batch.id, call_id="one")
started, release = asyncio.Event(), asyncio.Event()
execute = AsyncMock()
async def blocking(*args):
started.set()
await release.wait()
return tool_result_to_content({"written": True})
execute.side_effect = blocking
first = asyncio.create_task(execute_call(record, execute))
await started.wait()
second = await execute_call(record, execute)
self.assertEqual(second.status, "running")
first.cancel()
with self.assertRaises(asyncio.CancelledError):
await first
third = await execute_call(record, execute)
self.assertEqual(third.status, "running")
execute.assert_awaited_once()
async def test_running_operation_keeps_pending_and_never_calls_model(self):
batch = await self.batch()
await AgentApprovalCall.filter(batch_id=batch.id, call_id="one").update(status="running")
result = await AgentService.chat(self.request(batch, approved=["one", "two"]), self.user)
self.assertEqual(result["finish_reason"], "operation_in_progress")
self.assertEqual(len(result["pending_mcp_calls"]), 2)
self.model.assert_not_awaited()
self.assertFalse(self.executed)
async def test_http_and_sse_partial_approval_parity_and_400(self):
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_current_active_user] = lambda: self.user
batch = await self.batch()
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client:
invalid = await client.post("/api/agent/chat/stream", json={"approved_mcp_call_ids": ["one"]})
self.assertEqual(invalid.status_code, 400)
response = await client.post("/api/agent/chat", json=self.request(batch, approved=["one"]).model_dump())
self.assertEqual(response.status_code, 200, response.text)
normal = response.json()["data"]
response = await client.post("/api/agent/chat/stream", json=self.request(batch, approved=["one"]).model_dump())
self.assertEqual(response.status_code, 200, response.text)
events = [part for part in response.text.split("\n\n") if part.startswith("event: done")]
streamed = json.loads(events[-1].split("data: ", 1)[1])
self.assertEqual(streamed, normal)
self.assertEqual(len(self.executed), 1)
async def test_iteration_limit_is_visible_and_keeps_results(self):
self.model.return_value = {"role": "assistant", "content": "", "mcp_calls": [{"id": "time", "name": "time", "arguments": {}}]}
result = await AgentService.chat(AgentChatRequest(messages=[{"role": "user", "content": "loop"}]), self.user)
self.assertEqual(result["finish_reason"], "iteration_limit")
self.assertEqual(self.model.await_count, 8)
self.assertEqual(len([m for m in result["messages"] if m["role"] == "tool"]), 8)
async def test_missing_model_and_unsupported_provider_are_explicit(self):
self.patches[-1].stop()
with patch("domain.agent.service.AIProviderService.get_default_model", AsyncMock(return_value=None)):
result = await AgentService.chat(AgentChatRequest(), self.user)
self.assertEqual(result["finish_reason"], "model_unavailable")
with patch("domain.agent.service.AIProviderService.get_default_model", AsyncMock(return_value=SimpleNamespace(provider=SimpleNamespace(api_format="gemini")))):
with self.assertRaises(MissingModelError):
await _choose_chat_ability()
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
import os
import unittest
from unittest.mock import patch
import httpx
from domain.config.service import ConfigService
from domain.config.types import LatestVersionInfo
class LatestVersionTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.cache = {"timestamp": 0.0, "data": None}
cache_patch = patch.object(ConfigService, "_latest_version_cache", self.cache)
cache_patch.start()
self.addCleanup(cache_patch.stop)
async def test_fetch_with_unsupported_environment_proxy(self):
requests = []
async def respond(transport, request):
requests.append(request)
return httpx.Response(
200,
json={"tag_name": "v2.3.0", "body": "Release notes"},
request=request,
)
proxy_env = {
"ALL_PROXY": "socks://127.0.0.1:7897",
"all_proxy": "socks://127.0.0.1:7897",
"HTTP_PROXY": "http://127.0.0.1:7897",
"HTTPS_PROXY": "http://127.0.0.1:7897",
}
# Exercise real client initialization, including environment proxy parsing.
with patch.dict(os.environ, proxy_env, clear=True):
with patch.object(httpx.AsyncHTTPTransport, "handle_async_request", respond):
result = await ConfigService.get_latest_version()
cached_result = await ConfigService.get_latest_version()
self.assertEqual(result.latest_version, "v2.3.0")
self.assertEqual(result.body, "Release notes")
self.assertIs(cached_result, result)
self.assertEqual(len(requests), 1)
self.assertEqual(
str(requests[0].url),
"https://api.github.com/repos/DrizzleTime/Foxel/releases/latest",
)
async def test_failed_fetch_returns_cached_or_empty_info(self):
stale_info = LatestVersionInfo(latest_version="v2.2.3", body="Cached notes")
for failure in ("connection", 403, 500):
for cached_info in (None, stale_info):
with self.subTest(failure=failure, cached=bool(cached_info)):
self.cache.update(timestamp=0.0, data=cached_info)
async def respond(transport, request):
if failure == "connection":
raise httpx.ConnectError("Connection failed", request=request)
return httpx.Response(failure, request=request)
with patch.object(httpx.AsyncHTTPTransport, "handle_async_request", respond):
result = await ConfigService.get_latest_version()
self.assertEqual(result, cached_info or LatestVersionInfo())
self.assertEqual(self.cache["timestamp"], 0.0)
self.assertIs(self.cache["data"], cached_info)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
import os
import unittest
from unittest.mock import patch
import httpx
from domain.notices.service import NoticeService, REMOTE_NOTICES_URL
class NoticeFetchTests(unittest.IsolatedAsyncioTestCase):
async def test_fetch_with_unsupported_environment_proxy(self):
requests = []
async def respond(transport, request):
requests.append(request)
page = int(request.url.params["page"])
return httpx.Response(
200,
json={"items": [{"id": page}], "total": 2, "pageSize": 1},
request=request,
)
proxy_env = {
"ALL_PROXY": "socks://127.0.0.1:7897",
"all_proxy": "socks://127.0.0.1:7897",
"HTTP_PROXY": "http://127.0.0.1:7897",
"HTTPS_PROXY": "http://127.0.0.1:7897",
}
# Keep real client initialization so environment proxy parsing is exercised.
with patch.dict(os.environ, proxy_env, clear=True):
with patch.object(httpx.AsyncHTTPTransport, "handle_async_request", respond):
with patch("domain.notices.service.VERSION", "v1.2.3"):
items = await NoticeService._fetch_remote_notices()
self.assertEqual(items, [{"id": 1}, {"id": 2}])
self.assertEqual(len(requests), 2)
for request in requests:
self.assertEqual(str(request.url).split("?")[0], REMOTE_NOTICES_URL)
self.assertEqual(request.url.params["version"], "1.2.3")
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -9,6 +9,7 @@ export interface AgentChatContext {
export interface AgentChatRequest {
messages: AgentChatMessage[];
auto_execute?: boolean;
approval_batch_id?: string | null;
approved_mcp_call_ids?: string[];
rejected_mcp_call_ids?: string[];
context?: AgentChatContext;
@@ -25,11 +26,15 @@ export interface PendingMcpCall {
name: string;
arguments: Record<string, any>;
requires_confirmation: boolean;
status?: 'pending' | 'running';
}
export interface AgentChatResponse {
messages: AgentChatMessage[];
pending_mcp_calls?: PendingMcpCall[];
approval_batch_id?: string | null;
replace_messages?: boolean;
finish_reason?: string;
}
export type AgentSseEvent =
@@ -38,7 +43,7 @@ export type AgentSseEvent =
| { event: 'assistant_end'; data: { id: string; message: AgentChatMessage } }
| { event: 'mcp_call_start'; data: { mcp_call_id: string; name: string } }
| { event: 'mcp_call_end'; data: { mcp_call_id: string; name: string; message: AgentChatMessage } }
| { event: 'pending'; data: { pending_mcp_calls: PendingMcpCall[] } }
| { event: 'pending'; data: { pending_mcp_calls: PendingMcpCall[]; approval_batch_id?: string | null } }
| { event: 'done'; data: AgentChatResponse };
export const agentApi = {
+16 -11
View File
@@ -109,6 +109,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<AgentChatMessage[]>([]);
const [pending, setPending] = useState<PendingMcpCall[]>([]);
const [approvalBatchId, setApprovalBatchId] = useState<string | null>(null);
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
const [expandedRaw, setExpandedRaw] = useState<Record<string, boolean>>({});
const [runningTools, setRunningTools] = useState<Record<string, string>>({});
@@ -193,6 +194,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
{
messages: payload.messages,
auto_execute: autoExecute,
approval_batch_id: approvalBatchId,
context: effectivePath ? { current_path: effectivePath } : undefined,
approved_mcp_call_ids: payload.approved_mcp_call_ids,
rejected_mcp_call_ids: payload.rejected_mcp_call_ids,
@@ -267,14 +269,16 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
case 'pending': {
const items = Array.isArray((evt.data as any)?.pending_mcp_calls) ? (evt.data as any).pending_mcp_calls : [];
setPending(items);
setApprovalBatchId(evt.data.approval_batch_id || null);
return;
}
case 'done': {
const base = baseMessagesRef.current || [];
const newMessages = Array.isArray((evt.data as any)?.messages) ? (evt.data as any).messages : [];
const nextPending = Array.isArray((evt.data as any)?.pending_mcp_calls) ? (evt.data as any).pending_mcp_calls : [];
setMessages([...base, ...newMessages]);
setMessages(evt.data.replace_messages ? newMessages : [...base, ...newMessages]);
setPending(nextPending);
setApprovalBatchId(evt.data.approval_batch_id || null);
setRunningTools({});
assistantIndexRef.current = {};
return;
@@ -297,7 +301,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
}
}
}
}, [autoExecute, effectivePath, t]);
}, [autoExecute, approvalBatchId, effectivePath, t]);
const handleSend = useCallback(async () => {
const text = input.trim();
@@ -318,6 +322,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
streamControllerRef.current?.abort();
setMessages([]);
setPending([]);
setApprovalBatchId(null);
setExpandedTools({});
setExpandedRaw({});
setRunningTools({});
@@ -332,13 +337,13 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
}, [messages, runStream]);
const approveAll = useCallback(async () => {
const ids = pending.map((p) => p.id).filter(Boolean);
const ids = pending.filter((p) => p.status !== 'running').map((p) => p.id).filter(Boolean);
if (ids.length === 0) return;
await runStream({ messages, approved_mcp_call_ids: ids });
}, [messages, pending, runStream]);
const rejectAll = useCallback(async () => {
const ids = pending.map((p) => p.id).filter(Boolean);
const ids = pending.filter((p) => p.status !== 'running').map((p) => p.id).filter(Boolean);
if (ids.length === 0) return;
await runStream({ messages, rejected_mcp_call_ids: ids });
}, [messages, pending, runStream]);
@@ -444,7 +449,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
if (metaEntries.length === 0 && !title) return null;
return (
<>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
{title ? (
<Text type="secondary" style={{ fontSize: 12 }}>{title}</Text>
) : null}
@@ -614,7 +619,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
<Text strong>{t('AI Agent')}</Text>
<Space align="center">
<Text type="secondary">{t('Auto execute')}</Text>
<Switch size="small" checked={autoExecute} onChange={setAutoExecute} />
<Switch size="small" checked={autoExecute} onChange={setAutoExecute} disabled={loading || pending.length > 0} />
<Button
type="text"
size="small"
@@ -767,10 +772,10 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
<Text type="secondary">{pending.length}</Text>
</Space>
<Space size={6}>
<Button size="small" type="primary" onClick={approveAll} loading={loading}>
<Button size="small" type="primary" onClick={approveAll} loading={loading} disabled={pending.every((p) => p.status === 'running')}>
{t('Execute all')}
</Button>
<Button size="small" onClick={rejectAll} disabled={loading}>
<Button size="small" onClick={rejectAll} disabled={loading || pending.every((p) => p.status === 'running')}>
{t('Cancel all')}
</Button>
</Space>
@@ -781,7 +786,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
const args = p.arguments || {};
const key = `pending:${p.id}`;
const expanded = !!expandedTools[key];
const running = Object.prototype.hasOwnProperty.call(runningTools, p.id);
const running = p.status === 'running' || Object.prototype.hasOwnProperty.call(runningTools, p.id);
const summary = renderToolArgsSummary(args);
return (
<div key={p.id} className="fx-agent-tool-block fx-agent-pending-item">
@@ -801,14 +806,14 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
type="primary"
onClick={() => void approveOne(p.id)}
loading={loading && running}
disabled={loading && !running}
disabled={loading || p.status === 'running'}
>
{t('Execute')}
</Button>
<Button
size="small"
onClick={() => void rejectOne(p.id)}
disabled={loading && !running}
disabled={loading || p.status === 'running'}
>
{t('Cancel')}
</Button>
+1 -1
View File
@@ -275,7 +275,7 @@ const SideNav = memo(function SideNav({
open={open}
onClose={onClose}
title={null}
width={280}
size={280}
styles={{ body: { padding: 0 } }}
>
{renderNavBody(false, false)}
+1 -1
View File
@@ -282,7 +282,7 @@ const AdaptersPage = memo(function AdaptersPage() {
/>
<Drawer
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Adapter')}
width={480}
size={480}
open={open}
onClose={() => { setOpen(false); setEditing(null); }}
destroyOnHidden
+1 -1
View File
@@ -264,7 +264,7 @@ const AuditLogsPage = memo(function AuditLogsPage() {
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
>
{selectedLog && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<Descriptions
column={isMobile ? 1 : 2}
bordered
@@ -263,7 +263,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
placement="bottom"
onClose={onClose}
title={entry ? t('Actions') : t('Quick Actions')}
height="auto"
size="auto"
styles={{ body: { padding: 8 } }}
>
<div onClick={(e) => e.stopPropagation()}>
@@ -99,7 +99,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
borderBottom: idx === total - 1 ? 'none' : `1px solid ${token.colorSplit}`,
}}
>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
<Space size={[4, 4]} wrap>
{entry?.chunk_id && (
<Tag color="blue">{t('Chunk ID')}: {entry.chunk_id}</Tag>
@@ -125,7 +125,7 @@ const UploadModal: React.FC<UploadModalProps> = ({
</Button>,
]}
>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Space orientation="vertical" style={{ width: '100%' }} size={16}>
<div>
<Flex justify="space-between" align="center">
<Typography.Text strong>
+2 -2
View File
@@ -83,7 +83,7 @@ export default function LoginPage() {
}}
>
<div style={{ width: '100%', maxWidth: 360 }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}>
<img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} />
@@ -161,7 +161,7 @@ export default function LoginPage() {
Foxel 旨在提供一个安全、高效且智能的文件管理解决方案,帮助您轻松组织、访问和共享您的数字资产。
</Text>
<div style={{ marginTop: 32 }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<CloudSyncOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
+3 -3
View File
@@ -354,7 +354,7 @@ const ProcessorsPage = memo(function ProcessorsPage() {
</Space>
<Tag color={selected ? token.colorPrimary : token.colorBorderSecondary}>{item.type}</Tag>
</Flex>
<Space direction="vertical" size={6} style={{ marginTop: 8 }}>
<Space orientation="vertical" size={6} style={{ marginTop: 8 }}>
<div>
<Text type="secondary" style={{ marginRight: 8 }}>{t('Supported Extensions')}:</Text>
{item.supported_exts?.length ? (
@@ -446,7 +446,7 @@ const ProcessorsPage = memo(function ProcessorsPage() {
</Flex>
</Form.Item>
{isDirectory && supportsDirectory && (
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 12 }}>
<Space orientation="vertical" size={12} style={{ width: '100%', marginBottom: 12 }}>
<Alert
type="info"
showIcon
@@ -455,7 +455,7 @@ const ProcessorsPage = memo(function ProcessorsPage() {
</Space>
)}
{isDirectory && !supportsDirectory && (
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 12 }}>
<Space orientation="vertical" size={12} style={{ width: '100%', marginBottom: 12 }}>
<Alert
type="info"
showIcon
+1 -1
View File
@@ -58,7 +58,7 @@ export default function RegisterPage() {
</div>
<Card style={{ width: '100%', maxWidth: 420 }} styles={{ body: { padding: isMobile ? '20px 16px' : '24px' } }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Title level={2} style={{ marginBottom: 8 }}>{t('Create Account')}</Title>
<Text type="secondary">{t('Sign up to your Foxel account')}</Text>
+1 -1
View File
@@ -394,7 +394,7 @@ const SetupPage = () => {
</Form>
<div style={{ marginTop: isMobile ? 16 : 24 }}>
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: '100%' }}>
<Space orientation={isMobile ? 'vertical' : 'horizontal'} style={{ width: '100%' }}>
{currentStep > 0 && (
<Button block={isMobile} onClick={() => prev()}>
{t('Previous')}
@@ -88,7 +88,7 @@ const BackupPage = memo(function BackupPage() {
{t('Export selected data into a JSON file.')}
<Text strong>{t('Keep your backup file safe.')}</Text>
</Paragraph>
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
<Space orientation="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
<Text>{t('Select backup sections')}</Text>
<Checkbox.Group
options={exportOptions}
@@ -109,7 +109,7 @@ const BackupPage = memo(function BackupPage() {
<Paragraph>
{t('Restore data from a previously exported JSON file.')}
</Paragraph>
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
<Space orientation="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
<Text>{t('Import mode')}</Text>
<Radio.Group
optionType="button"
@@ -161,7 +161,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
<PageCard
title={t('System Settings')}
>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Space orientation="vertical" style={{ width: '100%' }} size={16}>
<Tabs
className="fx-settings-tabs"
activeKey={activeTab}
@@ -606,7 +606,7 @@ export default function AiSettingsTab() {
);
const renderRemoteModelsTab = () => (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<Space wrap>
<Button
icon={<ReloadOutlined />}
@@ -977,7 +977,7 @@ export default function AiSettingsTab() {
);
return (
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Space orientation="vertical" size={24} style={{ width: '100%' }}>
<div className="fx-ai-top-bar">
<div>
<Title level={3} style={{ marginBottom: 4 }}>{t('AI Providers & Models')}</Title>
@@ -1006,7 +1006,7 @@ export default function AiSettingsTab() {
</Row>
<Card className="fx-ai-defaults-card" title={t('Default Models Configuration')}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
{abilityOrder.map((ability) => {
const info = abilityInfo[ability];
const options = collectionsByAbility(ability);
@@ -1043,13 +1043,13 @@ export default function AiSettingsTab() {
</Card>
<Drawer
width={640}
size={640}
open={providerModal.open}
title={providerModal.editing ? t('Edit Provider') : t('Add Provider')}
onClose={handleCloseProviderModal}
footer={drawerFooter}
>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Space orientation="vertical" size={24} style={{ width: '100%' }}>
<Steps
size="small"
current={providerModal.step - 1}
@@ -1200,7 +1200,7 @@ export default function AiSettingsTab() {
</Drawer>
<Drawer
width={520}
size={520}
open={modelModal.open}
title={modelModal.editing ? t('Edit Model') : t('Add Model')}
onClose={handleCloseModelModal}
@@ -1213,7 +1213,7 @@ export default function AiSettingsTab() {
</Space>
)}
>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Space orientation="vertical" size={24} style={{ width: '100%' }}>
{modelModal.editing ? (
renderManualModelForm()
) : (
@@ -207,7 +207,7 @@ export default function EmailSettingsTab({ config, loading, onSave }: EmailSetti
};
return (
<Space direction="vertical" size={32} style={{ width: '100%', marginTop: 24 }}>
<Space orientation="vertical" size={32} style={{ width: '100%', marginTop: 24 }}>
<Row gutter={24}>
<Col xs={24} lg={15}>
<Card
@@ -296,7 +296,7 @@ export default function EmailSettingsTab({ config, loading, onSave }: EmailSetti
</Card>
</Col>
<Col xs={24} lg={9}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Space orientation="vertical" size={24} style={{ width: '100%' }}>
<Card title={t('Current Configuration')} bodyStyle={{ paddingBottom: 12 }}>
<Descriptions column={1} size="small" colon={false}>
{summary.map(item => (
@@ -131,7 +131,7 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
};
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<Card
title={t('WebDAV Mapping')}
extra={(
@@ -191,7 +191,7 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
/>
)}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
{!hasS3Credentials && (
<Alert
type="warning"
@@ -189,8 +189,8 @@ export default function VectorDbSettingsTab({ isActive }: VectorDbSettingsTabPro
return (
<Card title={t('Vector Database Settings')} style={{ marginTop: 24 }}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space orientation="vertical" size={24} style={{ width: '100%' }}>
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<strong>{t('Current Statistics')}</strong>
<Button onClick={() => { fetchVectorMeta(); fetchVectorStats(); }} loading={vectorStatsLoading || vectorConfigLoading} disabled={(vectorStatsLoading || vectorConfigLoading) && !vectorStats}>
@@ -207,7 +207,7 @@ export default function VectorDbSettingsTab({ isActive }: VectorDbSettingsTabPro
<Alert type="error" showIcon message={vectorMetaError} />
) : null}
{vectorStats ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
<div>
<div style={{ color: '#888' }}>{t('Collections')}</div>
@@ -227,10 +227,10 @@ export default function VectorDbSettingsTab({ isActive }: VectorDbSettingsTabPro
</div>
</div>
{vectorStats.collections.length ? (
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Space orientation="vertical" style={{ width: '100%' }} size={16}>
{vectorStats.collections.map((collection) => (
<div key={collection.name} style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 16 }}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<strong>{collection.name}</strong>
<span style={{ color: '#888' }}>
@@ -244,7 +244,7 @@ export default function VectorDbSettingsTab({ isActive }: VectorDbSettingsTabPro
<div>{t('Estimated memory')}: {formatBytes(collection.estimated_memory_bytes)}</div>
) : null}
{collection.indexes.length ? (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
<span>{t('Indexes')}:</span>
<ul style={{ paddingLeft: 20, margin: 0 }}>
{collection.indexes.map((index) => (
@@ -329,7 +329,7 @@ export default function VectorDbSettingsTab({ isActive }: VectorDbSettingsTabPro
/>
) : null}
<Form.Item>
<Space direction="vertical" style={{ width: '100%' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
<Button
type="primary"
htmlType="submit"
+1 -1
View File
@@ -170,7 +170,7 @@ const TasksPage = memo(function TasksPage() {
/>
<Drawer
title={editing ? `${t('Edit Task')}: ${editing.name}` : t('Create Automation Task')}
width={480}
size={480}
open={open}
onClose={() => { setOpen(false); setEditing(null); setPathPickerField(null); }}
destroyOnHidden
@@ -25,7 +25,7 @@ export const PathRuleEditorDrawer = memo(function PathRuleEditorDrawer({
return (
<Drawer
title={editingRule ? t('Edit Path Rule') : t('Add Path Rule')}
width={400}
size={400}
open={open}
onClose={onClose}
destroyOnHidden
@@ -52,7 +52,7 @@ export const PathRuleEditorDrawer = memo(function PathRuleEditorDrawer({
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Divider>{t('Permissions')}</Divider>
<Space direction="vertical" style={{ width: '100%' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
<Form.Item name="can_read" valuePropName="checked" noStyle>
<Checkbox>{t('Read')} - {t('Download and preview files')}</Checkbox>
</Form.Item>
@@ -159,7 +159,7 @@ export const RoleEditorDrawer = memo(function RoleEditorDrawer({
? category.charAt(0).toUpperCase() + category.slice(1)
: t(`permission.category.${category}`),
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
{perms.map(p => (
<Checkbox key={p.code} value={p.code}>
{p.name}
@@ -186,7 +186,7 @@ export const RoleEditorDrawer = memo(function RoleEditorDrawer({
key: 'path_rules',
label: `${t('Path Rules')} (${pathRules.length})`,
children: (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Space orientation="vertical" style={{ width: '100%' }} size={12}>
<Space>
<Button type="primary" size="small" onClick={onAddPathRule}>
{t('Add Path Rule')}
@@ -226,7 +226,7 @@ export const RoleEditorDrawer = memo(function RoleEditorDrawer({
return (
<Drawer
title={editingRole ? `${t('Edit')}: ${editingRole.name}` : t('Create Role')}
width={600}
size={600}
open={open}
onClose={onClose}
destroyOnHidden
@@ -30,7 +30,7 @@ export const UserEditorDrawer = memo(function UserEditorDrawer({
return (
<Drawer
title={editingUser ? `${t('Edit')}: ${editingUser.username}` : t('Create User')}
width={480}
size={480}
open={open}
onClose={onClose}
destroyOnHidden