1 Commits

Author SHA1 Message Date
ffa1249403 feat: integrate MinIO-backed user filesystem 2026-06-03 12:06:34 +08:00
56 changed files with 4810 additions and 116 deletions

View File

@ -7,8 +7,8 @@ BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
BEAVER_DEPLOY_TOKEN=change-me BEAVER_DEPLOY_TOKEN=change-me
BEAVER_AUTHZ_INTERNAL_TOKEN=change-me BEAVER_AUTHZ_INTERNAL_TOKEN=change-me
BEAVER_SERVER_IP=203.0.113.10 BEAVER_SERVER_IP=127.0.0.1
BEAVER_BASE_DOMAIN=203.0.113.10.nip.io BEAVER_BASE_DOMAIN=localhost
BEAVER_PROVIDER=openai BEAVER_PROVIDER=openai
BEAVER_MODEL=openai/gpt-5 BEAVER_MODEL=openai/gpt-5

View File

@ -72,7 +72,7 @@ export BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)" export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)" export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io export BEAVER_BASE_DOMAIN=localhost
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090' export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090' export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
@ -110,14 +110,14 @@ http://beaver-authz-service:19090
```bash ```bash
DEPLOY_PUBLIC_SCHEME=http DEPLOY_PUBLIC_SCHEME=http
DEPLOY_PUBLIC_BASE_DOMAIN=127.0.0.1.nip.io DEPLOY_PUBLIC_BASE_DOMAIN=localhost
DEPLOY_PUBLIC_PORT=8088 DEPLOY_PUBLIC_PORT=8088
``` ```
本机测试时实例 URL 形如: 本机测试时实例 URL 形如:
```text ```text
http://alice.127.0.0.1.nip.io:8088 http://alice.localhost:8088
``` ```
正式 HTTPS 域名通常改成: 正式 HTTPS 域名通常改成:

View File

@ -47,6 +47,12 @@ from beaver.tools.builtins import (
SkillsListTool, SkillsListTool,
TerminalTool, TerminalTool,
TodoTool, TodoTool,
UserFilesCopyToWorkspaceTool,
UserFilesListTool,
UserFilesMkdirTool,
UserFilesPublishOutputTool,
UserFilesReadTool,
UserFilesWriteTool,
WebFetchTool, WebFetchTool,
WebSearchTool, WebSearchTool,
WriteFileTool, WriteFileTool,
@ -222,6 +228,12 @@ class EngineLoader:
ObjectBackedTool(SearchFilesTool()), ObjectBackedTool(SearchFilesTool()),
ObjectBackedTool(WriteFileTool()), ObjectBackedTool(WriteFileTool()),
ObjectBackedTool(PatchFileTool()), ObjectBackedTool(PatchFileTool()),
ObjectBackedTool(UserFilesListTool()),
ObjectBackedTool(UserFilesReadTool()),
ObjectBackedTool(UserFilesWriteTool()),
ObjectBackedTool(UserFilesMkdirTool()),
ObjectBackedTool(UserFilesCopyToWorkspaceTool()),
ObjectBackedTool(UserFilesPublishOutputTool()),
ObjectBackedTool(WebFetchTool()), ObjectBackedTool(WebFetchTool()),
ObjectBackedTool(WebSearchTool()), ObjectBackedTool(WebSearchTool()),
ObjectBackedTool(TerminalTool()), ObjectBackedTool(TerminalTool()),

View File

@ -621,11 +621,17 @@ class AgentLoop:
"tool_registry": tool_registry, "tool_registry": tool_registry,
"skills_loader": skills_loader, "skills_loader": skills_loader,
"draft_service": getattr(loaded, "draft_service", None), "draft_service": getattr(loaded, "draft_service", None),
"beaver_config": loaded.config,
"task_id": task_id,
"run_id": resolved_run_id,
**self.runtime_services, **self.runtime_services,
}, },
metadata={ metadata={
"source": source, "source": source,
"agent_name": self.profile.name, "agent_name": self.profile.name,
"session_id": resolved_session_id,
"task_id": task_id,
"run_id": resolved_run_id,
}, },
) )

View File

@ -12,6 +12,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sqlite3 import sqlite3
import threading import threading
import time import time
@ -110,6 +111,12 @@ END;
""" """
def _sqlite_journal_mode() -> str:
requested = os.getenv("BEAVER_SQLITE_JOURNAL_MODE", "DELETE").strip().upper()
allowed = {"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "OFF", "WAL"}
return requested if requested in allowed else "DELETE"
class SessionStore: class SessionStore:
"""SQLite-backed session store.""" """SQLite-backed session store."""
@ -119,7 +126,9 @@ class SessionStore:
self._lock = threading.Lock() self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False, isolation_level=None) self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False, isolation_level=None)
self._conn.row_factory = sqlite3.Row self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL") self._conn.execute("PRAGMA mmap_size=0")
self._conn.execute("PRAGMA busy_timeout=5000")
self._conn.execute(f"PRAGMA journal_mode={_sqlite_journal_mode()}")
self._conn.execute("PRAGMA foreign_keys=ON") self._conn.execute("PRAGMA foreign_keys=ON")
self._init_schema() self._init_schema()

View File

@ -20,7 +20,7 @@ from .schema import (
) )
LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = { LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
"local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"}, "local_filesystem_mcp": {"category": "filesystem", "display_name": "个人智能体文件系统工具"},
"local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"}, "local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"},
"local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"}, "local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"},
"local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"}, "local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"},

View File

@ -109,3 +109,15 @@ class AuthzClient:
async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]: async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]:
data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook") data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook")
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
async def get_minio_settings(self, backend_id: str) -> dict[str, Any]:
data = await self._request("GET", f"/backends/{backend_id}/settings/minio")
return data if isinstance(data, dict) else {}
async def set_minio_settings(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
data = await self._request("POST", f"/backends/{backend_id}/settings/minio", json_body=payload)
return data if isinstance(data, dict) else {}
async def delete_minio_settings(self, backend_id: str) -> dict[str, Any]:
data = await self._request("DELETE", f"/backends/{backend_id}/settings/minio")
return data if isinstance(data, dict) else {}

View File

@ -27,12 +27,8 @@ from beaver.tools.builtins import (
CronTool, CronTool,
DelegateTool, DelegateTool,
ExecuteCodeTool, ExecuteCodeTool,
ListDirectoryTool,
MemoryTool, MemoryTool,
PatchFileTool,
ProcessTool, ProcessTool,
ReadFileTool,
SearchFilesTool,
SendMessageTool, SendMessageTool,
SkillManageTool, SkillManageTool,
SkillViewTool, SkillViewTool,
@ -40,6 +36,12 @@ from beaver.tools.builtins import (
SpawnTool, SpawnTool,
TerminalTool, TerminalTool,
TodoTool, TodoTool,
UserFilesCopyToWorkspaceTool,
UserFilesListTool,
UserFilesMkdirTool,
UserFilesPublishOutputTool,
UserFilesReadTool,
UserFilesWriteTool,
WebFetchTool, WebFetchTool,
WebSearchTool, WebSearchTool,
WriteFileTool, WriteFileTool,
@ -47,7 +49,7 @@ from beaver.tools.builtins import (
LOCAL_TOOL_CATEGORIES = { LOCAL_TOOL_CATEGORIES = {
"filesystem": "Beaver Local Filesystem Tools", "filesystem": "Beaver Personal Agent Filesystem Tools",
"runtime": "Beaver Local Runtime Tools", "runtime": "Beaver Local Runtime Tools",
"memory": "Beaver Local Memory Tools", "memory": "Beaver Local Memory Tools",
"skills": "Beaver Local Skills Tools", "skills": "Beaver Local Skills Tools",
@ -84,11 +86,12 @@ def _category_tools(category: str, workspace: Path) -> tuple[list[BaseTool], Too
if category == "filesystem": if category == "filesystem":
tools: list[BaseTool] = [ tools: list[BaseTool] = [
ObjectBackedTool(ListDirectoryTool()), ObjectBackedTool(UserFilesListTool()),
ObjectBackedTool(ReadFileTool()), ObjectBackedTool(UserFilesReadTool()),
ObjectBackedTool(SearchFilesTool()), ObjectBackedTool(UserFilesWriteTool()),
ObjectBackedTool(WriteFileTool()), ObjectBackedTool(UserFilesMkdirTool()),
ObjectBackedTool(PatchFileTool()), ObjectBackedTool(UserFilesCopyToWorkspaceTool()),
ObjectBackedTool(UserFilesPublishOutputTool()),
] ]
elif category == "runtime": elif category == "runtime":
tools = [ tools = [

View File

@ -24,6 +24,19 @@ from beaver.integrations.mcp import MCPConnectionManager
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
from beaver.services.cron_service import CronService, schedule_from_api from beaver.services.cron_service import CronService, schedule_from_api
from beaver.services.skillhub_service import SkillHubService from beaver.services.skillhub_service import SkillHubService
from beaver.services.user_files import (
USER_FILE_ROOTS,
UserFileError,
UserFileNotFoundError,
UserFilePathError,
UserFileSizeError,
UserFileService,
)
from beaver.services.user_file_resolver import (
UserFileConfigurationError,
UserFileStorageResolver,
build_file_auth_context,
)
from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig
from beaver.skills.catalog.utils import parse_frontmatter from beaver.skills.catalog.utils import parse_frontmatter
@ -306,6 +319,28 @@ def create_app(
app.state.handoff_codes = {} app.state.handoff_codes = {}
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "") app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
max_file_size = 50 * 1024 * 1024 max_file_size = 50 * 1024 * 1024
max_user_file_upload_size = _int_env("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", 5 * 1024 * 1024 * 1024)
user_file_upload_part_size = _int_env("BEAVER_USER_FILES_UPLOAD_PART_SIZE", 10 * 1024 * 1024)
def _user_file_resolver(request: Request, authorization: str | None) -> UserFileStorageResolver:
username = _require_web_user(app, authorization)
loaded = get_agent_service(request).create_loop().boot()
auth_context = build_file_auth_context(username=username, config=loaded.config)
return UserFileStorageResolver(config=loaded.config, workspace=loaded.workspace, auth_context=auth_context)
async def _user_file_service(request: Request, authorization: str | None) -> UserFileService:
return await _user_file_resolver(request, authorization).service()
def _user_file_http_error(exc: UserFileError) -> HTTPException:
if isinstance(exc, UserFileNotFoundError):
return HTTPException(status_code=404, detail=str(exc) or "File not found")
if isinstance(exc, UserFilePathError):
return HTTPException(status_code=400, detail=str(exc) or "Invalid path")
if isinstance(exc, UserFileSizeError):
return HTTPException(status_code=413, detail=str(exc) or "File too large")
if isinstance(exc, UserFileConfigurationError):
return HTTPException(status_code=503, detail=str(exc) or "User file storage is not configured")
return HTTPException(status_code=400, detail=str(exc) or "User file operation failed")
@app.get("/api/ping", response_model=WebStatusResponse) @app.get("/api/ping", response_model=WebStatusResponse)
async def ping(request: Request) -> WebStatusResponse: async def ping(request: Request) -> WebStatusResponse:
@ -747,6 +782,101 @@ def create_app(
return {"ok": True} return {"ok": True}
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
@app.get("/api/user-files/status")
async def user_files_status(
request: Request,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
return (await _user_file_resolver(request, authorization).status()).to_dict()
@app.get("/api/user-files/browse")
async def browse_user_files(
request: Request,
path: str = "",
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
try:
return await (await _user_file_service(request, authorization)).browse(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
@app.get("/api/user-files/download")
async def download_user_file(
path: str,
request: Request,
authorization: str | None = Header(default=None),
) -> Response:
try:
content = await (await _user_file_service(request, authorization)).download(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
disposition = "inline" if content.content_type.startswith("image/") else "attachment"
return Response(
content=content.content,
media_type=content.content_type,
headers={"Content-Disposition": content_disposition(disposition, content.name)},
)
@app.get("/api/user-files/preview")
async def preview_user_file(
path: str,
request: Request,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
try:
return await (await _user_file_service(request, authorization)).preview(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
@app.post("/api/user-files/upload")
async def upload_user_file(
request: Request,
file: UploadFile = File(...),
path: str = Form("uploads"),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
file_size = getattr(file, "size", None)
if isinstance(file_size, int) and file_size > max_user_file_upload_size:
raise HTTPException(status_code=413, detail=f"File too large (max {_human_upload_size(max_user_file_upload_size)})")
try:
return await (await _user_file_service(request, authorization)).upload_stream(
path,
file.filename,
file.file,
content_type=file.content_type or "application/octet-stream",
max_bytes=max_user_file_upload_size,
part_size=user_file_upload_part_size,
)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
@app.delete("/api/user-files/delete")
async def delete_user_file(
path: str,
request: Request,
authorization: str | None = Header(default=None),
) -> dict[str, bool]:
try:
removed = await (await _user_file_service(request, authorization)).delete(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
if removed:
return {"ok": True}
raise HTTPException(status_code=404, detail="Path not found")
@app.post("/api/user-files/mkdir")
async def create_user_file_directory(
path: str,
request: Request,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
try:
return await (await _user_file_service(request, authorization)).mkdir(path)
except UserFileError as exc:
raise _user_file_http_error(exc) from exc
@app.get("/api/workspace/browse") @app.get("/api/workspace/browse")
async def browse_workspace_dir(request: Request, path: str = "") -> dict[str, Any]: async def browse_workspace_dir(request: Request, path: str = "") -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot() loaded = get_agent_service(request).create_loop().boot()
@ -2576,6 +2706,27 @@ def _handoff_replay_window_seconds() -> int:
return 15 return 15
def _int_env(name: str, default: int) -> int:
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
return default
return value if value > 0 else default
def _human_upload_size(size: int) -> str:
units = ("B", "KB", "MB", "GB", "TB")
value = float(size)
for unit in units:
if value < 1024 or unit == units[-1]:
return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
value /= 1024
return f"{size}B"
def _prune_handoff_codes(app: FastAPI) -> None: def _prune_handoff_codes(app: FastAPI) -> None:
now = time.time() now = time.time()
replay_window = _handoff_replay_window_seconds() replay_window = _handoff_replay_window_seconds()

View File

@ -0,0 +1,201 @@
"""Resolve the user-visible file system for web and agent callers."""
from __future__ import annotations
from dataclasses import dataclass, field
import os
from pathlib import Path
from typing import Any
import httpx
from beaver.foundation.config.schema import BeaverConfig
from .user_files import (
LocalUserFileStorage,
MinIOStorageConfig,
MinIOUserFileStorage,
USER_FILE_ROOTS,
UserFileError,
UserFileService,
)
class UserFileConfigurationError(UserFileError):
"""Raised when user file storage is not configured for this backend."""
@dataclass(slots=True)
class FileAuthContext:
"""Authenticated identity used by the personal file system boundary."""
username: str
backend_id: str
storage_namespace: str
user_id: str | None = None
scopes: tuple[str, ...] = field(default_factory=tuple)
auth_source: str = "beaver-web-token"
@dataclass(slots=True)
class UserFileStorageStatus:
configured: bool
storage_mode: str
roots: list[str]
workspace_visible: bool = False
detail: str | None = None
def to_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"configured": self.configured,
"storage_mode": self.storage_mode,
"roots": self.roots,
"workspace_visible": self.workspace_visible,
}
if self.detail:
payload["detail"] = self.detail
return payload
class UserFileStorageResolver:
"""Build `UserFileService` from the current Beaver identity and config."""
def __init__(
self,
*,
config: BeaverConfig,
workspace: Path,
auth_context: FileAuthContext,
) -> None:
self.config = config
self.workspace = Path(workspace)
self.auth_context = auth_context
async def service(self) -> UserFileService:
mode = _storage_mode(self.config)
if mode == "local":
return UserFileService(LocalUserFileStorage(self.workspace / "user_files"))
settings = await self._load_minio_settings()
return UserFileService(
MinIOUserFileStorage(
MinIOStorageConfig(
endpoint=str(settings.get("endpoint") or ""),
access_key=str(settings.get("access_key") or ""),
secret_key=str(settings.get("secret_key") or ""),
bucket=str(settings.get("bucket") or ""),
secure=bool(settings.get("secure", False)),
region=_clean_optional(settings.get("region")),
namespace=str(settings.get("namespace") or self.auth_context.storage_namespace),
)
)
)
async def status(self) -> UserFileStorageStatus:
mode = _storage_mode(self.config)
if mode == "local":
return UserFileStorageStatus(
configured=True,
storage_mode="local",
roots=list(USER_FILE_ROOTS),
workspace_visible=False,
)
try:
await self._load_minio_settings()
except UserFileConfigurationError as exc:
return UserFileStorageStatus(
configured=False,
storage_mode="object",
roots=list(USER_FILE_ROOTS),
workspace_visible=False,
detail=str(exc),
)
return UserFileStorageStatus(
configured=True,
storage_mode="object",
roots=list(USER_FILE_ROOTS),
workspace_visible=False,
)
async def _load_minio_settings(self) -> dict[str, Any]:
backend_id = self.auth_context.backend_id.strip()
if not backend_id:
raise UserFileConfigurationError("User file storage backend identity is not configured")
base_url = self.config.authz.base_url.strip()
if not (self.config.authz.enabled and base_url):
raise UserFileConfigurationError("AuthZ is required for deployed user file storage")
token = (
os.getenv("BEAVER_AUTHZ_INTERNAL_TOKEN", "").strip()
or os.getenv("AUTHZ_INTERNAL_TOKEN", "").strip()
)
if not token:
raise UserFileConfigurationError("AuthZ internal token is not configured for user file storage")
try:
async with httpx.AsyncClient(
timeout=self.config.authz.request_timeout_seconds,
follow_redirects=True,
trust_env=False,
) as client:
response = await client.get(
f"{base_url.rstrip('/')}/internal/backends/{backend_id}/settings/minio",
headers={"Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
raise UserFileConfigurationError(f"Unable to load user file storage settings: {exc}") from exc
if response.status_code == 404:
raise UserFileConfigurationError("MinIO user file storage is not configured")
if response.is_error:
raise UserFileConfigurationError(
f"Unable to load user file storage settings: HTTP {response.status_code}"
)
payload = response.json()
if not isinstance(payload, dict):
raise UserFileConfigurationError("Invalid MinIO settings response")
if not all(str(payload.get(key) or "").strip() for key in ("endpoint", "access_key", "secret_key", "bucket")):
raise UserFileConfigurationError("MinIO user file storage settings are incomplete")
payload.setdefault("namespace", self.auth_context.storage_namespace)
return payload
def build_file_auth_context(
*,
username: str,
config: BeaverConfig,
user_id: str | None = None,
scopes: tuple[str, ...] = (),
auth_source: str = "beaver-web-token",
) -> FileAuthContext:
backend_id = (
config.backend_identity.backend_id.strip()
or os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID", "").strip()
or username.strip()
)
namespace = default_user_file_namespace(backend_id)
return FileAuthContext(
username=username.strip(),
backend_id=backend_id,
storage_namespace=namespace,
user_id=user_id,
scopes=scopes,
auth_source=auth_source,
)
def default_user_file_namespace(backend_id: str) -> str:
cleaned = backend_id.strip().strip("/")
return f"users/{cleaned}" if cleaned else "users/unconfigured"
def _storage_mode(config: BeaverConfig) -> str:
raw = os.getenv("BEAVER_USER_FILES_STORAGE_MODE", "").strip().lower()
if raw in {"local", "dev-local", "development"}:
return "local"
if raw in {"minio", "object", "object-storage"}:
return "minio"
if config.authz.enabled and config.authz.base_url.strip() and config.backend_identity.backend_id.strip():
return "minio"
return "local"
def _clean_optional(value: Any) -> str | None:
text = str(value or "").strip()
return text or None

View File

@ -0,0 +1,630 @@
"""User-visible file system service.
This module owns the personal file-system boundary exposed to users and
agents. Storage backends can change, but callers see only virtual paths under
fixed roots.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timezone
from io import BytesIO
import mimetypes
from pathlib import Path, PurePosixPath
import shutil
import tempfile
from typing import Protocol
USER_FILE_ROOTS = ("uploads", "outputs", "shared", "tasks")
MAX_PREVIEW_BYTES = 1024 * 1024
AGENT_UPLOADS_ERROR = "uploads/ is user-provided input storage; agents may read it but must not write it"
AGENT_DELETE_ERROR = "agents cannot delete user-visible files; use the Files page or user-side APIs"
class UserFileError(ValueError):
"""Base error for user file operations."""
class UserFilePathError(UserFileError):
"""Raised when a user file path violates the virtual path policy."""
class UserFileNotFoundError(UserFileError):
"""Raised when a user file path does not exist."""
class UserFileSizeError(UserFileError):
"""Raised when a user file upload exceeds configured limits."""
@dataclass(frozen=True, slots=True)
class AgentUserFilePolicy:
task_id: str | None = None
fallback_scope: str = "interactive"
@property
def task_namespace(self) -> str:
if self.task_id:
return f"tasks/{self.task_id}"
scope = _safe_scope(self.fallback_scope)
return f"tasks/interactive/{scope}"
def validate_read(self, path: str) -> str:
return normalize_user_path(path, allow_root=False)
def validate_write(self, path: str) -> str:
normalized = normalize_user_path(path, allow_root=False)
root = normalized.split("/", 1)[0]
if root == "uploads":
raise UserFilePathError(AGENT_UPLOADS_ERROR)
if root == "tasks":
self._validate_task_namespace(normalized)
return normalized
def validate_mkdir(self, path: str) -> str:
return self.validate_write(path)
def validate_delete(self, path: str) -> str:
normalize_user_path(path, allow_root=False)
raise UserFilePathError(AGENT_DELETE_ERROR)
def _validate_task_namespace(self, normalized: str) -> None:
namespace = self.task_namespace
if normalized == "tasks" or not normalized.startswith(f"{namespace}/"):
raise UserFilePathError(f"Agent task files must be written under {namespace}/")
@dataclass(slots=True)
class UserFileEntry:
name: str
path: str
type: str
size: int | None = None
content_type: str | None = None
modified: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"name": self.name,
"path": self.path,
"type": self.type,
"size": self.size,
"content_type": self.content_type,
"modified": self.modified,
}
@dataclass(slots=True)
class UserFileContent:
name: str
path: str
size: int
content_type: str
modified: str | None
content: bytes
@dataclass(slots=True)
class UserFilePreview:
name: str
path: str
size: int
content_type: str
modified: str | None
is_binary: bool
is_truncated: bool
content: str | None
def to_dict(self) -> dict[str, object]:
return {
"name": self.name,
"path": self.path,
"size": self.size,
"content_type": self.content_type,
"modified": self.modified,
"is_binary": self.is_binary,
"is_truncated": self.is_truncated,
"content": self.content,
}
class UserFileStorage(Protocol):
async def list_dir(self, path: str) -> list[UserFileEntry]:
...
async def read_file(self, path: str, *, max_bytes: int | None = None) -> UserFileContent:
...
async def write_file(self, path: str, content: bytes, *, content_type: str) -> UserFileEntry:
...
async def write_file_stream(
self,
path: str,
stream: object,
*,
content_type: str,
max_bytes: int | None = None,
part_size: int = 10 * 1024 * 1024,
) -> UserFileEntry:
...
async def delete_path(self, path: str) -> bool:
...
async def mkdir(self, path: str) -> UserFileEntry:
...
class UserFileService:
def __init__(self, storage: UserFileStorage) -> None:
self.storage = storage
async def browse(self, path: str = "") -> dict[str, object]:
normalized = normalize_user_path(path, allow_root=True)
if normalized == "":
return {
"path": "",
"items": [
UserFileEntry(name=root, path=root, type="directory").to_dict()
for root in USER_FILE_ROOTS
],
}
entries = await self.storage.list_dir(normalized)
return {"path": normalized, "items": [entry.to_dict() for entry in entries]}
async def upload(self, directory: str, filename: str, content: bytes, *, content_type: str) -> dict[str, object]:
if not is_safe_filename(filename):
raise UserFilePathError("Invalid filename")
target = normalize_user_path(_join_user_path(directory, filename), allow_root=False)
return (await self.storage.write_file(target, content, content_type=content_type)).to_dict()
async def upload_stream(
self,
directory: str,
filename: str,
stream: object,
*,
content_type: str,
max_bytes: int | None = None,
part_size: int = 10 * 1024 * 1024,
) -> dict[str, object]:
if not is_safe_filename(filename):
raise UserFilePathError("Invalid filename")
target = normalize_user_path(_join_user_path(directory, filename), allow_root=False)
return (
await self.storage.write_file_stream(
target,
stream,
content_type=content_type,
max_bytes=max_bytes,
part_size=part_size,
)
).to_dict()
async def write_file(self, path: str, content: bytes | str, *, content_type: str = "text/plain") -> dict[str, object]:
normalized = normalize_user_path(path, allow_root=False)
raw = content.encode("utf-8") if isinstance(content, str) else bytes(content)
return (await self.storage.write_file(normalized, raw, content_type=content_type)).to_dict()
async def download(self, path: str) -> UserFileContent:
return await self.storage.read_file(normalize_user_path(path, allow_root=False))
async def preview(self, path: str, *, max_bytes: int = MAX_PREVIEW_BYTES) -> dict[str, object]:
content = await self.storage.read_file(normalize_user_path(path, allow_root=False), max_bytes=max_bytes)
is_binary = _is_probably_binary(content.content, content.content_type)
text = None if is_binary else content.content.decode("utf-8", errors="replace")
return UserFilePreview(
name=content.name,
path=content.path,
size=content.size,
content_type=content.content_type,
modified=content.modified,
is_binary=is_binary,
is_truncated=content.size > len(content.content),
content=text,
).to_dict()
async def delete(self, path: str) -> bool:
normalized = normalize_user_path(path, allow_root=False)
if normalized in USER_FILE_ROOTS:
raise UserFilePathError("Cannot delete virtual root folders")
return await self.storage.delete_path(normalized)
async def mkdir(self, path: str) -> dict[str, object]:
normalized = normalize_user_path(path, allow_root=False)
if normalized in USER_FILE_ROOTS:
raise UserFilePathError("Virtual root folders already exist")
return (await self.storage.mkdir(normalized)).to_dict()
class LocalUserFileStorage:
"""Filesystem-backed storage adapter for tests and local development."""
def __init__(self, root: Path) -> None:
self.root = Path(root).expanduser().resolve()
self.root.mkdir(parents=True, exist_ok=True)
for name in USER_FILE_ROOTS:
(self.root / name).mkdir(parents=True, exist_ok=True)
async def list_dir(self, path: str) -> list[UserFileEntry]:
target = self._path(path)
if not target.exists():
target.mkdir(parents=True, exist_ok=True)
if not target.is_dir():
raise UserFilePathError("Path is not a directory")
entries: list[UserFileEntry] = []
for child in sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
if child.name.startswith("."):
continue
entries.append(self._entry(child))
return entries
async def read_file(self, path: str, *, max_bytes: int | None = None) -> UserFileContent:
target = self._path(path)
if not target.is_file():
raise UserFileNotFoundError("File not found")
raw = target.read_bytes()
selected = raw[:max_bytes] if max_bytes is not None else raw
stat = target.stat()
content_type, _ = mimetypes.guess_type(target.name)
return UserFileContent(
name=target.name,
path=self._relative(target),
size=stat.st_size,
content_type=content_type or "application/octet-stream",
modified=_iso_from_timestamp(stat.st_mtime),
content=selected,
)
async def write_file(self, path: str, content: bytes, *, content_type: str) -> UserFileEntry:
target = self._path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
return self._entry(target, content_type=content_type)
async def write_file_stream(
self,
path: str,
stream: object,
*,
content_type: str,
max_bytes: int | None = None,
part_size: int = 10 * 1024 * 1024,
) -> UserFileEntry:
target = self._path(path)
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
tmp_path = Path(tmp_name)
total = 0
try:
with open(fd, "wb", closefd=True) as output:
while True:
chunk = stream.read(part_size) # type: ignore[attr-defined]
if not chunk:
break
total += len(chunk)
if max_bytes is not None and total > max_bytes:
raise UserFileSizeError(_size_error(max_bytes))
output.write(chunk)
tmp_path.replace(target)
except Exception:
with suppress(FileNotFoundError):
tmp_path.unlink()
raise
return self._entry(target, content_type=content_type)
async def delete_path(self, path: str) -> bool:
target = self._path(path)
if not target.exists():
return False
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
return True
async def mkdir(self, path: str) -> UserFileEntry:
target = self._path(path)
target.mkdir(parents=True, exist_ok=True)
return self._entry(target)
def _path(self, path: str) -> Path:
normalized = normalize_user_path(path, allow_root=False)
target = (self.root / normalized).resolve()
try:
target.relative_to(self.root)
except ValueError as exc:
raise UserFilePathError("Path escapes user file root") from exc
return target
def _relative(self, path: Path) -> str:
return path.relative_to(self.root).as_posix()
def _entry(self, path: Path, *, content_type: str | None = None) -> UserFileEntry:
stat = path.stat()
guessed_type, _ = mimetypes.guess_type(path.name)
return UserFileEntry(
name=path.name,
path=self._relative(path),
type="directory" if path.is_dir() else "file",
size=None if path.is_dir() else stat.st_size,
content_type=None if path.is_dir() else (content_type or guessed_type or "application/octet-stream"),
modified=_iso_from_timestamp(stat.st_mtime),
)
@dataclass(slots=True)
class MinIOStorageConfig:
endpoint: str
access_key: str
secret_key: str
bucket: str
secure: bool = False
region: str | None = None
namespace: str = ""
class MinIOUserFileStorage:
"""MinIO-backed user file storage adapter."""
def __init__(self, config: MinIOStorageConfig) -> None:
if not config.endpoint or not config.access_key or not config.secret_key or not config.bucket:
raise ValueError("MinIO storage requires endpoint, access key, secret key, and bucket")
from minio import Minio
self.config = config
self.client = Minio(
endpoint=config.endpoint,
access_key=config.access_key,
secret_key=config.secret_key,
secure=config.secure,
region=config.region,
)
async def list_dir(self, path: str) -> list[UserFileEntry]:
prefix = self._object_prefix(path)
objects = self.client.list_objects(self.config.bucket, prefix=prefix, recursive=False)
entries: list[UserFileEntry] = []
for obj in objects:
object_name = str(obj.object_name or "")
user_path = self._user_path(object_name)
if not user_path or user_path == path or user_path.endswith("/.keep"):
continue
trimmed = user_path.rstrip("/")
name = PurePosixPath(trimmed).name
is_dir = bool(getattr(obj, "is_dir", False)) or object_name.endswith("/")
entries.append(
UserFileEntry(
name=name,
path=trimmed,
type="directory" if is_dir else "file",
size=None if is_dir else getattr(obj, "size", None),
content_type=None if is_dir else "application/octet-stream",
modified=obj.last_modified.isoformat() if getattr(obj, "last_modified", None) else None,
)
)
return sorted(entries, key=lambda item: (item.type != "directory", item.name.lower()))
async def read_file(self, path: str, *, max_bytes: int | None = None) -> UserFileContent:
object_name = self._object_name(path)
try:
stat = self.client.stat_object(self.config.bucket, object_name)
if max_bytes is None:
response = self.client.get_object(self.config.bucket, object_name)
else:
response = self.client.get_object(self.config.bucket, object_name, length=max_bytes)
raw = response.read()
response.close()
response.release_conn()
except Exception as exc:
raise UserFileNotFoundError("File not found") from exc
return UserFileContent(
name=PurePosixPath(path).name,
path=path,
size=int(stat.size or len(raw)),
content_type=stat.content_type or "application/octet-stream",
modified=stat.last_modified.isoformat() if stat.last_modified else None,
content=raw,
)
async def write_file(self, path: str, content: bytes, *, content_type: str) -> UserFileEntry:
object_name = self._object_name(path)
result = self.client.put_object(
self.config.bucket,
object_name,
BytesIO(content),
length=len(content),
content_type=content_type,
)
return UserFileEntry(
name=PurePosixPath(path).name,
path=path,
type="file",
size=len(content),
content_type=content_type,
modified=datetime.now(timezone.utc).isoformat(),
)
async def write_file_stream(
self,
path: str,
stream: object,
*,
content_type: str,
max_bytes: int | None = None,
part_size: int = 10 * 1024 * 1024,
) -> UserFileEntry:
object_name = self._object_name(path)
reader = _LimitedReadStream(stream, max_bytes=max_bytes)
try:
self.client.put_object(
self.config.bucket,
object_name,
reader,
length=-1,
part_size=max(5 * 1024 * 1024, part_size),
content_type=content_type,
)
except UserFileSizeError:
try:
self.client.remove_object(self.config.bucket, object_name)
except Exception:
pass
raise
return UserFileEntry(
name=PurePosixPath(path).name,
path=path,
type="file",
size=reader.bytes_read,
content_type=content_type,
modified=datetime.now(timezone.utc).isoformat(),
)
async def delete_path(self, path: str) -> bool:
object_name = self._object_name(path)
removed = False
try:
self.client.remove_object(self.config.bucket, object_name)
removed = True
except Exception:
pass
prefix = f"{object_name.rstrip('/')}/"
for obj in self.client.list_objects(self.config.bucket, prefix=prefix, recursive=True):
self.client.remove_object(self.config.bucket, str(obj.object_name))
removed = True
return removed
async def mkdir(self, path: str) -> UserFileEntry:
object_name = f"{self._object_name(path).rstrip('/')}/.keep"
self.client.put_object(
self.config.bucket,
object_name,
BytesIO(b""),
length=0,
content_type="application/x-directory",
)
return UserFileEntry(
name=PurePosixPath(path).name,
path=path,
type="directory",
size=None,
modified=datetime.now(timezone.utc).isoformat(),
)
def _namespace(self) -> str:
return self.config.namespace.strip("/")
def _object_name(self, path: str) -> str:
normalized = normalize_user_path(path, allow_root=False)
namespace = self._namespace()
object_name = f"{namespace}/{normalized}" if namespace else normalized
if object_name.startswith("/") or "/../" in f"/{object_name}/":
raise UserFilePathError("Object path escapes namespace")
return object_name
def _object_prefix(self, path: str) -> str:
return f"{self._object_name(path).rstrip('/')}/"
def _user_path(self, object_name: str) -> str:
namespace = self._namespace()
if namespace:
prefix = f"{namespace}/"
if not object_name.startswith(prefix):
raise UserFilePathError("Object path escapes namespace")
return object_name[len(prefix) :]
return object_name
def normalize_user_path(path: str | None, *, allow_root: bool) -> str:
original = (path or "").replace("\\", "/").strip()
if original.startswith("/"):
raise UserFilePathError("Absolute paths are not allowed")
raw = original.strip("/")
if raw == "":
if allow_root:
return ""
raise UserFilePathError("Path is required")
posix = PurePosixPath(raw)
if posix.is_absolute():
raise UserFilePathError("Absolute paths are not allowed")
parts = [part for part in posix.parts if part not in ("", ".")]
if any(part == ".." for part in parts):
raise UserFilePathError("Parent-directory traversal is not allowed")
if any(part.startswith(".") for part in parts):
raise UserFilePathError("Hidden implementation paths are not allowed")
if not parts or parts[0] not in USER_FILE_ROOTS:
raise UserFilePathError("Path must be under uploads, outputs, shared, or tasks")
return "/".join(parts)
def is_safe_filename(filename: str) -> bool:
return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".")
def _join_user_path(directory: str, filename: str) -> str:
normalized_dir = normalize_user_path(directory, allow_root=False)
return f"{normalized_dir.rstrip('/')}/{filename}"
def _is_probably_binary(raw: bytes, content_type: str) -> bool:
if content_type.startswith("text/") or content_type in {
"application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
}:
return False
if not raw:
return False
if b"\x00" in raw[:4096]:
return True
try:
raw[:4096].decode("utf-8")
except UnicodeDecodeError:
return True
return False
def _iso_from_timestamp(value: float) -> str:
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
def _safe_scope(value: str | None) -> str:
raw = (value or "interactive").strip()
allowed = [char if char.isalnum() or char in ("-", "_") else "-" for char in raw]
cleaned = "".join(allowed).strip("-_")
return cleaned or "interactive"
class _LimitedReadStream:
def __init__(self, stream: object, *, max_bytes: int | None = None) -> None:
self.stream = stream
self.max_bytes = max_bytes
self.bytes_read = 0
def read(self, size: int = -1) -> bytes:
chunk = self.stream.read(size) # type: ignore[attr-defined]
if not chunk:
return b""
self.bytes_read += len(chunk)
if self.max_bytes is not None and self.bytes_read > self.max_bytes:
raise UserFileSizeError(_size_error(self.max_bytes))
return chunk
def _size_error(max_bytes: int) -> str:
return f"File too large (max {_human_size(max_bytes)})"
def _human_size(size: int) -> str:
units = ("B", "KB", "MB", "GB", "TB")
value = float(size)
for unit in units:
if value < 1024 or unit == units[-1]:
return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
value /= 1024
return f"{size}B"

View File

@ -180,8 +180,10 @@ class ObjectBackedTool(BaseTool):
if "current_session_id" not in arguments and hasattr(self.backend, "current_session_id"): if "current_session_id" not in arguments and hasattr(self.backend, "current_session_id"):
arguments["current_session_id"] = context.session_id arguments["current_session_id"] = context.session_id
if "workspace" not in arguments and hasattr(self.backend, "workspace"): if "workspace" not in arguments and (hasattr(self.backend, "workspace") or self._backend_accepts_argument("workspace")):
arguments["workspace"] = context.workspace arguments["workspace"] = context.workspace
if "services" not in arguments and self._backend_accepts_argument("services"):
arguments["services"] = context.services
if "metadata" not in arguments and self._backend_accepts_argument("metadata"): if "metadata" not in arguments and self._backend_accepts_argument("metadata"):
arguments["metadata"] = context.metadata arguments["metadata"] = context.metadata

View File

@ -9,6 +9,15 @@ from .skill_view import SkillViewTool, skill_view
from .session_search import SessionSearchTool, session_search from .session_search import SessionSearchTool, session_search
from .terminal import ExecuteCodeTool, ProcessTool, TerminalTool from .terminal import ExecuteCodeTool, ProcessTool, TerminalTool
from .utility import ClarifyTool, DelegateTool, SendMessageTool, SpawnTool, TodoTool from .utility import ClarifyTool, DelegateTool, SendMessageTool, SpawnTool, TodoTool
from .user_files import (
UserFilesCopyToWorkspaceTool,
UserFilesDeleteTool,
UserFilesListTool,
UserFilesMkdirTool,
UserFilesPublishOutputTool,
UserFilesReadTool,
UserFilesWriteTool,
)
from .web import WebFetchTool, WebSearchTool from .web import WebFetchTool, WebSearchTool
__all__ = [ __all__ = [
@ -30,6 +39,13 @@ __all__ = [
"SessionSearchTool", "SessionSearchTool",
"TerminalTool", "TerminalTool",
"TodoTool", "TodoTool",
"UserFilesCopyToWorkspaceTool",
"UserFilesDeleteTool",
"UserFilesListTool",
"UserFilesMkdirTool",
"UserFilesPublishOutputTool",
"UserFilesReadTool",
"UserFilesWriteTool",
"ClarifyTool", "ClarifyTool",
"WebFetchTool", "WebFetchTool",
"WebSearchTool", "WebSearchTool",

View File

@ -14,7 +14,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
import json import json
from pathlib import Path from pathlib import Path, PurePosixPath
from typing import Any, Iterable from typing import Any, Iterable
@ -24,6 +24,7 @@ MAX_READ_CHARS = 120_000
MAX_SEARCH_RESULTS = 200 MAX_SEARCH_RESULTS = 200
MAX_SEARCH_FILE_BYTES = 2_000_000 MAX_SEARCH_FILE_BYTES = 2_000_000
MAX_SEARCH_FILES = 5_000 MAX_SEARCH_FILES = 5_000
USER_FILE_VIRTUAL_ROOTS = {"uploads", "outputs", "shared", "tasks"}
SKIP_DIR_NAMES = { SKIP_DIR_NAMES = {
".git", ".git",
".hg", ".hg",
@ -161,9 +162,28 @@ def _workspace_root(workspace: str | None) -> Path:
return root return root
def _virtual_user_file_error(user_path: str | None) -> str | None:
raw = str(user_path or ".").replace("\\", "/").strip()
if not raw or raw in {".", "./"}:
return None
try:
parts = [part for part in PurePosixPath(raw.strip("/")).parts if part not in ("", ".")]
except TypeError:
return None
if parts and parts[0] in USER_FILE_VIRTUAL_ROOTS:
return (
f"{user_path} is a personal agent file system path, not a workspace path. "
"Use user_files_read or user_files_copy_to_workspace for reads; use "
"user_files_write for shared/tasks files or user_files_publish_output for outputs."
)
return None
def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]: def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]:
"""Resolve a user path and ensure the real target stays inside workspace.""" """Resolve a user path and ensure the real target stays inside workspace."""
if error := _virtual_user_file_error(user_path):
raise WorkspacePathError(error)
root = _workspace_root(workspace) root = _workspace_root(workspace)
raw_path = Path(user_path or ".").expanduser() raw_path = Path(user_path or ".").expanduser()
candidate = raw_path if raw_path.is_absolute() else root / raw_path candidate = raw_path if raw_path.is_absolute() else root / raw_path
@ -178,6 +198,8 @@ def _resolve_existing_path(workspace: str | None, user_path: str | None) -> tupl
def _resolve_writable_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]: def _resolve_writable_path(workspace: str | None, user_path: str | None) -> tuple[Path, Path]:
if error := _virtual_user_file_error(user_path):
raise WorkspacePathError(error)
root = _workspace_root(workspace) root = _workspace_root(workspace)
if not user_path or not str(user_path).strip(): if not user_path or not str(user_path).strip():
raise WorkspacePathError("path is required") raise WorkspacePathError("path is required")

View File

@ -0,0 +1,389 @@
"""Agent-facing tools for the user-visible file system."""
from __future__ import annotations
from dataclasses import dataclass, field
import json
import mimetypes
from pathlib import Path
from typing import Any
from beaver.foundation.config.loader import load_config
from beaver.services.user_file_resolver import UserFileStorageResolver, build_file_auth_context
from beaver.services.user_files import AgentUserFilePolicy, UserFileError, UserFilePathError, UserFileService
MAX_WORKSPACE_STAGE_BYTES = 50 * 1024 * 1024
def _json_result(success: bool, **payload: Any) -> str:
return json.dumps({"success": success, **payload}, ensure_ascii=False, indent=2)
async def _service(workspace: str | None, services: dict[str, Any] | None = None) -> UserFileService:
if not workspace:
raise UserFileError("workspace is not configured for user file tools")
config = (services or {}).get("beaver_config")
if config is None:
config = load_config(workspace=workspace)
backend_id = config.backend_identity.backend_id.strip() or config.backend_identity.client_id.strip() or "agent"
auth_context = build_file_auth_context(
username=backend_id,
config=config,
user_id=(services or {}).get("user_id"),
auth_source="beaver-agent-runtime",
)
return await UserFileStorageResolver(
config=config,
workspace=Path(workspace),
auth_context=auth_context,
).service()
def _agent_policy(services: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> AgentUserFilePolicy:
payload = services or {}
meta = metadata or {}
task_id = str(payload.get("task_id") or meta.get("task_id") or "").strip() or None
fallback = str(payload.get("run_id") or meta.get("run_id") or meta.get("session_id") or "interactive")
return AgentUserFilePolicy(task_id=task_id, fallback_scope=fallback)
def _workspace_root(workspace: str | None) -> Path:
if not workspace:
raise UserFilePathError("workspace is not configured for user file tools")
root = Path(workspace).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def _resolve_workspace_source(workspace: str | None, source_path: str) -> tuple[Path, Path]:
root = _workspace_root(workspace)
if not source_path or not str(source_path).strip():
raise UserFilePathError("source_path is required")
raw = Path(str(source_path)).expanduser()
candidate = raw if raw.is_absolute() else root / raw
resolved = candidate.resolve(strict=True)
try:
resolved.relative_to(root)
except ValueError as exc:
raise UserFilePathError("source_path escapes workspace") from exc
if not resolved.is_file():
raise UserFilePathError("source_path must be a file")
return root, resolved
def _resolve_workspace_destination(workspace: str | None, target_path: str) -> tuple[Path, Path]:
root = _workspace_root(workspace)
if not target_path or not str(target_path).strip():
raise UserFilePathError("workspace_path is required")
raw = Path(str(target_path)).expanduser()
if raw.is_absolute():
raise UserFilePathError("workspace_path must be relative")
candidate = (root / raw).resolve()
try:
candidate.relative_to(root)
except ValueError as exc:
raise UserFilePathError("workspace_path escapes workspace") from exc
return root, candidate
def _relative_path(root: Path, path: Path) -> str:
return path.relative_to(root).as_posix()
USER_FILES_LIST_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"path": {
"type": "string",
"default": "",
"description": "User file path under uploads, outputs, shared, or tasks. Empty path lists the virtual roots.",
}
},
}
USER_FILES_READ_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"path": {"type": "string", "description": "User file path to read."},
"max_bytes": {
"type": "integer",
"default": 120000,
"minimum": 1,
"maximum": 1000000,
"description": "Maximum bytes to return in model context.",
},
},
"required": ["path"],
}
USER_FILES_WRITE_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"path": {"type": "string", "description": "User file path to create or replace."},
"content": {"type": "string", "description": "Text content to write."},
"content_type": {"type": "string", "default": "text/plain"},
},
"required": ["path", "content"],
}
USER_FILES_DELETE_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {"path": {"type": "string", "description": "User file or directory path to delete."}},
"required": ["path"],
}
USER_FILES_MKDIR_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {"path": {"type": "string", "description": "User file directory path to create."}},
"required": ["path"],
}
USER_FILES_COPY_TO_WORKSPACE_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Readable user file path under uploads, outputs, shared, or an authorized tasks namespace.",
},
"workspace_path": {
"type": "string",
"description": "Optional relative workspace destination. Defaults to user-files/tasks/{task_id}/<filename> or user-files/runs/<scope>/<filename>.",
},
},
"required": ["path"],
}
USER_FILES_PUBLISH_OUTPUT_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"source_path": {
"type": "string",
"description": "Workspace file path to publish. Absolute paths are allowed only if they stay inside the workspace.",
},
"target_path": {
"type": "string",
"description": "Output path under outputs/, such as outputs/report.md.",
},
"content_type": {
"type": "string",
"description": "Optional content type. If omitted, Beaver guesses from the target filename.",
},
},
"required": ["source_path", "target_path"],
}
@dataclass(slots=True)
class UserFilesListTool:
name: str = "user_files_list"
description: str = (
"List files and folders in the personal agent file system. Use the virtual roots only: "
"uploads for files the user provides to the agent, outputs for agent-generated results, "
"shared for reusable user/agent reference material, and tasks for files bound to a specific task. "
"An empty path lists the four roots; this tool never exposes MinIO buckets, credentials, or internal workspace paths."
)
toolset: str = "user_files"
always_available: bool = True
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_LIST_PARAMETERS))
async def execute(self, *, path: str = "", workspace: str | None = None, services: dict[str, Any] | None = None) -> str:
try:
return _json_result(True, **await (await _service(workspace, services)).browse(path))
except UserFileError as exc:
return _json_result(False, error=str(exc), path=path)
@dataclass(slots=True)
class UserFilesReadTool:
name: str = "user_files_read"
description: str = (
"Read a bounded text preview from the personal agent file system. Use this to inspect user-provided "
"files in uploads, long-lived shared material in shared, task files in tasks, or generated outputs in outputs. "
"The path must stay under uploads, outputs, shared, or tasks; internal workspace and MinIO implementation paths are hidden."
)
toolset: str = "user_files"
always_available: bool = True
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_READ_PARAMETERS))
async def execute(
self,
*,
path: str,
max_bytes: int = 120000,
workspace: str | None = None,
services: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> str:
try:
path = _agent_policy(services, metadata).validate_read(path)
limit = max(1, min(int(max_bytes), 1_000_000))
return _json_result(True, **await (await _service(workspace, services)).preview(path, max_bytes=limit))
except UserFileError as exc:
return _json_result(False, error=str(exc), path=path)
@dataclass(slots=True)
class UserFilesWriteTool:
name: str = "user_files_write"
description: str = (
"Create or replace a text file in the personal agent file system. Store agent-generated deliverables "
"under outputs, reusable long-lived context under shared, and task-bound files under the current "
"tasks/{task_id}/ namespace. Never write to uploads; uploaded files are immutable agent inputs. "
"For modifications to uploaded files, copy them to the workspace, edit there, then publish to outputs."
)
toolset: str = "user_files"
always_available: bool = False
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_WRITE_PARAMETERS))
async def execute(
self,
*,
path: str,
content: str,
content_type: str = "text/plain",
workspace: str | None = None,
services: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> str:
try:
path = _agent_policy(services, metadata).validate_write(path)
return _json_result(True, **await (await _service(workspace, services)).write_file(path, content, content_type=content_type))
except UserFileError as exc:
return _json_result(False, error=str(exc), path=path)
@dataclass(slots=True)
class UserFilesDeleteTool:
name: str = "user_files_delete"
description: str = (
"Agent deletion is disabled for the personal agent file system. User-visible file deletion is owned by "
"the Files page or user-side APIs; agents should use task/workspace cleanup instead."
)
toolset: str = "user_files"
always_available: bool = False
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_DELETE_PARAMETERS))
async def execute(self, *, path: str, workspace: str | None = None, services: dict[str, Any] | None = None) -> str:
try:
_agent_policy(services).validate_delete(path)
return _json_result(False, path=path, deleted=False)
except UserFileError as exc:
return _json_result(False, error=str(exc), path=path)
@dataclass(slots=True)
class UserFilesMkdirTool:
name: str = "user_files_mkdir"
description: str = (
"Create a subfolder in the personal agent file system under uploads, outputs, shared, or tasks. "
"Use folders to organize agent outputs, reusable shared material, or current task-specific files. "
"Do not create folders under uploads because uploads is user-owned input storage."
)
toolset: str = "user_files"
always_available: bool = False
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_MKDIR_PARAMETERS))
async def execute(
self,
*,
path: str,
workspace: str | None = None,
services: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> str:
try:
path = _agent_policy(services, metadata).validate_mkdir(path)
return _json_result(True, **await (await _service(workspace, services)).mkdir(path))
except UserFileError as exc:
return _json_result(False, error=str(exc), path=path)
@dataclass(slots=True)
class UserFilesCopyToWorkspaceTool:
name: str = "user_files_copy_to_workspace"
description: str = (
"Copy a readable file from the personal agent file system into the internal workspace before editing, "
"running, or validating it. Use this for user-uploaded files under uploads: the original upload remains "
"unchanged, and the returned workspace_path can be used with workspace tools like read_file or patch_file."
)
toolset: str = "user_files"
always_available: bool = False
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_COPY_TO_WORKSPACE_PARAMETERS))
async def execute(
self,
*,
path: str,
workspace_path: str | None = None,
workspace: str | None = None,
services: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> str:
try:
policy = _agent_policy(services, metadata)
path = policy.validate_read(path)
content = await (await _service(workspace, services)).download(path)
if content.size > MAX_WORKSPACE_STAGE_BYTES:
raise UserFilePathError(f"File is too large to copy to workspace (max {MAX_WORKSPACE_STAGE_BYTES} bytes)")
default_path = f"user-files/{policy.task_namespace}/{Path(path).name}"
root, destination = _resolve_workspace_destination(workspace, workspace_path or default_path)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(content.content)
return _json_result(
True,
path=path,
workspace_path=_relative_path(root, destination),
bytes=len(content.content),
content_type=content.content_type,
)
except UserFileError as exc:
return _json_result(False, error=str(exc), path=path)
except OSError as exc:
return _json_result(False, error=str(exc), path=path)
@dataclass(slots=True)
class UserFilesPublishOutputTool:
name: str = "user_files_publish_output"
description: str = (
"Publish a validated workspace file to the personal agent file system under outputs/. Use this after "
"staging and editing files in the workspace. Publishing never writes to uploads, and it hides MinIO "
"bucket, namespace, and credential details from the agent."
)
toolset: str = "user_files"
always_available: bool = False
parameters: dict[str, Any] = field(default_factory=lambda: dict(USER_FILES_PUBLISH_OUTPUT_PARAMETERS))
async def execute(
self,
*,
source_path: str,
target_path: str,
content_type: str | None = None,
workspace: str | None = None,
services: dict[str, Any] | None = None,
) -> str:
try:
root, source = _resolve_workspace_source(workspace, source_path)
normalized_target = target_path.strip().strip("/")
if not normalized_target.startswith("outputs/"):
raise UserFilePathError("Published output target must be under outputs/")
guessed_type, _ = mimetypes.guess_type(normalized_target)
raw = source.read_bytes()
entry = await (await _service(workspace, services)).write_file(
normalized_target,
raw,
content_type=content_type or guessed_type or "application/octet-stream",
)
return _json_result(
True,
source_path=_relative_path(root, source),
target_path=normalized_target,
bytes=len(raw),
**entry,
)
except UserFileError as exc:
return _json_result(False, error=str(exc), source_path=source_path, target_path=target_path)
except OSError as exc:
return _json_result(False, error=str(exc), source_path=source_path, target_path=target_path)

View File

@ -0,0 +1,104 @@
# User File System MinIO/AuthZ Setup
The user file system is exposed through Beaver APIs and `user_files_*` tools. MinIO remains an implementation detail.
The ordinary Files page should only call Beaver's `/api/user-files/*` routes and render the virtual roots `uploads/`, `outputs/`, `shared/`, and `tasks/`. It should not show bucket names, endpoint fields, access keys, secret keys, object prefixes, or MinIO administration actions.
## AuthZ Settings
Each backend identity can store MinIO settings in AuthZ:
```bash
curl -X POST "$AUTHZ_URL/backends/$BACKEND_ID/settings/minio" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTHZ_ADMIN_TOKEN" \
-d '{
"endpoint": "minio.example.internal:9000",
"access_key": "user-access-key",
"secret_key": "user-secret-key",
"bucket": "beaver-user-files",
"namespace": "users/{backend_id}",
"secure": false,
"region": null
}'
```
Public reads return masked settings. Internal reads require `AUTHZ_INTERNAL_TOKEN` and return the secret key for protected MCP services.
Deployed personal files use a shared bucket with a backend-scoped namespace. For backend `alice`, Beaver maps:
- `uploads/report.pdf` to `users/alice/uploads/report.pdf`
- `outputs/summary.md` to `users/alice/outputs/summary.md`
- `tasks/task-123/result.json` to `users/alice/tasks/task-123/result.json`
The MinIO policy for Alice's access key must be limited to `beaver-user-files/users/alice/*`. The frontend must still only show Beaver virtual paths, not the shared bucket or namespace.
Check the public, masked view:
```bash
curl "$AUTHZ_URL/backends/$BACKEND_ID/settings/minio" \
-H "Authorization: Bearer $AUTHZ_ADMIN_TOKEN"
```
Check the internal protected view used by MCP services:
```bash
curl "$AUTHZ_URL/internal/backends/$BACKEND_ID/settings/minio" \
-H "Authorization: Bearer $AUTHZ_INTERNAL_TOKEN"
```
## Protected MinIO MCP
Run the MinIO MCP service in protected mode:
```bash
bw-minio-mcp serve \
--host 0.0.0.0 \
--port 8001 \
--authz-url "$AUTHZ_URL" \
--authz-token "$AUTHZ_INTERNAL_TOKEN" \
--resource-server-url "$MINIO_MCP_PUBLIC_URL/mcp" \
--state-root /var/lib/bw-minio-mcp
```
In protected mode, the MCP service does not use static MinIO credentials at startup. Each authenticated tool call resolves the backend identity from the bearer token, loads that backend's MinIO settings from AuthZ, and constructs a per-call provider.
Outside protected mode, `bw-minio-mcp serve` requires explicit `--endpoint`, `--access-key`, and `--secret-key` values. It intentionally has no embedded production fallback credentials.
## Beaver Runtime
Beaver should register the MinIO MCP endpoint with backend-token auth when raw object tools are needed:
```json
{
"tools": {
"mcpServers": {
"minio_mcp": {
"url": "https://minio-mcp.example.internal/mcp",
"auth": "oauth_backend_token",
"authAudience": "mcp:minio_mcp"
}
}
},
"authz": {
"baseUrl": "https://authz.example.internal",
"backendId": "backend-user-id"
}
}
```
Product-level file interactions should still go through Beaver's user file system:
- Frontend: `/api/user-files/status`, `/api/user-files/browse`, `/api/user-files/upload`, `/api/user-files/preview`, `/api/user-files/download`, `/api/user-files/delete`, and `/api/user-files/mkdir`.
- Agent tools: `user_files_list`, `user_files_read`, `user_files_write`, `user_files_delete`, and `user_files_mkdir`.
- Storage boundary: only `uploads/`, `outputs/`, `shared/`, and `tasks/` are valid user paths.
The local workspace browser APIs and generic filesystem tools are retained for runtime/development compatibility, but they are not the user-visible file boundary.
## Verification Checklist
- The Files page root renders exactly `uploads`, `outputs`, `shared`, and `tasks`.
- The Files page source does not call `/api/workspace/browse`.
- `/api/user-files/status` does not return local workspace paths or MinIO bucket details.
- AuthZ public settings responses mask `secret_key`.
- Protected `BW_MinIO_Mcp` returns a clear configuration error if a backend has no MinIO settings instead of falling back to another user's credentials.

View File

@ -0,0 +1,12 @@
# User File System Tooling Boundary
The `personal-user-filesystem` change adds `user_files_*` tools for files that users can upload, inspect, and receive from agents. These tools enforce the same virtual roots as the web API:
- `uploads/`
- `outputs/`
- `shared/`
- `tasks/`
The existing local workspace filesystem tools remain registered for internal runtime and development workflows. They are workspace-scoped, but they are not the user-visible file boundary. Agents should use `user_files_*` tools when reading user-provided files or writing user-facing outputs.
Follow-up for stronger isolation: add a runtime policy switch that disables or narrows local workspace filesystem tools for ordinary personal-agent tasks, while keeping `user_files_*` available.

View File

@ -11,6 +11,7 @@ dependencies = [
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
"json-repair>=0.39.0,<1.0.0", "json-repair>=0.39.0,<1.0.0",
"litellm>=1.79.0,<2.0.0", "litellm>=1.79.0,<2.0.0",
"minio>=7.2.0,<8.0.0",
"openai>=1.79.0,<2.0.0", "openai>=1.79.0,<2.0.0",
"pydantic>=2.12.0,<3.0.0", "pydantic>=2.12.0,<3.0.0",
"python-multipart>=0.0.20,<1.0.0", "python-multipart>=0.0.20,<1.0.0",

View File

@ -199,4 +199,5 @@ def test_load_config_adds_managed_local_mcp_servers(tmp_path) -> None:
assert local.kind == "local" assert local.kind == "local"
assert local.category == "filesystem" assert local.category == "filesystem"
assert local.managed is True assert local.managed is True
assert local.display_name == "个人智能体文件系统工具"
assert "beaver.interfaces.mcp.tools_server" in local.args assert "beaver.interfaces.mcp.tools_server" in local.args

View File

@ -6,7 +6,7 @@ import os
from pathlib import Path from pathlib import Path
from beaver.tools import ObjectBackedTool, ToolContext from beaver.tools import ObjectBackedTool, ToolContext
from beaver.tools.builtins import ListDirectoryTool, ReadFileTool, SearchFilesTool from beaver.tools.builtins import ListDirectoryTool, PatchFileTool, ReadFileTool, SearchFilesTool, WriteFileTool
def _run_tool(tool, arguments: dict, workspace: Path): def _run_tool(tool, arguments: dict, workspace: Path):
@ -127,3 +127,23 @@ def test_read_file_rejects_binary_files(tmp_path: Path) -> None:
assert payload["success"] is False assert payload["success"] is False
assert "binary" in payload["error"] assert "binary" in payload["error"]
def test_workspace_tools_reject_user_file_virtual_paths(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
read = _run_tool(ReadFileTool(), {"path": "uploads/get_helm.sh"}, workspace)
listed = _run_tool(ListDirectoryTool(), {"path": "outputs"}, workspace)
written = _run_tool(WriteFileTool(), {"path": "shared/profile.json", "content": "{}"}, workspace)
patched = _run_tool(
PatchFileTool(),
{"path": "tasks/task-123/draft.md", "old_text": "a", "new_text": "b"},
workspace,
)
for result in (read, listed, written, patched):
payload = _payload(result)
assert result.success is False
assert payload["success"] is False
assert "personal agent file system path" in payload["error"]
assert "user_files_read" in payload["error"]

View File

@ -0,0 +1,22 @@
from __future__ import annotations
from beaver.interfaces.mcp.tools_server import _category_tools
def test_local_filesystem_mcp_exposes_personal_user_file_tools_only(tmp_path) -> None:
tools, _context = _category_tools("filesystem", tmp_path)
names = [tool.spec.name for tool in tools]
assert names == [
"user_files_list",
"user_files_read",
"user_files_write",
"user_files_mkdir",
"user_files_copy_to_workspace",
"user_files_publish_output",
]
assert "read_file" not in names
assert "search_files" not in names
assert "list_directory" not in names
assert all("personal agent file system" in tool.spec.description for tool in tools)

View File

@ -0,0 +1,153 @@
from __future__ import annotations
from io import BytesIO
import pytest
from beaver.services.user_files import (
LocalUserFileStorage,
MinIOStorageConfig,
MinIOUserFileStorage,
UserFileNotFoundError,
UserFilePathError,
UserFileSizeError,
UserFileService,
normalize_user_path,
)
def test_normalize_user_path_accepts_fixed_roots() -> None:
assert normalize_user_path("uploads/readme.txt", allow_root=False) == "uploads/readme.txt"
assert normalize_user_path("outputs/report.md", allow_root=False) == "outputs/report.md"
assert normalize_user_path("tasks/task-123/draft.md", allow_root=False) == "tasks/task-123/draft.md"
assert normalize_user_path("", allow_root=True) == ""
@pytest.mark.parametrize(
"path",
[
"../secret.txt",
"/uploads/input.txt",
"/outputs/result.txt",
"/shared/profile.json",
"/tasks/task-123/draft.md",
"uploads/../state/config.json",
"memory/private.txt",
"uploads/.internal",
"",
],
)
def test_normalize_user_path_rejects_invalid_paths(path: str) -> None:
with pytest.raises(UserFilePathError):
normalize_user_path(path, allow_root=False)
@pytest.mark.asyncio
async def test_user_file_service_root_and_round_trip(tmp_path) -> None:
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
root = await service.browse("")
uploaded = await service.upload(
"uploads",
"hello.txt",
b"hello user files",
content_type="text/plain",
)
uploads = await service.browse("uploads")
preview = await service.preview("uploads/hello.txt")
downloaded = await service.download("uploads/hello.txt")
deleted = await service.delete("uploads/hello.txt")
assert [item["name"] for item in root["items"]] == ["uploads", "outputs", "shared", "tasks"]
assert uploaded["path"] == "uploads/hello.txt"
assert uploaded["content_type"] == "text/plain"
assert [item["name"] for item in uploads["items"]] == ["hello.txt"]
assert preview["content"] == "hello user files"
assert downloaded.content == b"hello user files"
assert deleted is True
@pytest.mark.asyncio
async def test_user_file_service_stream_upload_and_size_limit(tmp_path) -> None:
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
uploaded = await service.upload_stream(
"uploads",
"streamed.txt",
BytesIO(b"streamed user file"),
content_type="text/plain",
max_bytes=1024,
part_size=4,
)
preview = await service.preview("uploads/streamed.txt")
assert uploaded["path"] == "uploads/streamed.txt"
assert uploaded["size"] == len(b"streamed user file")
assert preview["content"] == "streamed user file"
with pytest.raises(UserFileSizeError):
await service.upload_stream(
"uploads",
"too-large.txt",
BytesIO(b"abcdef"),
content_type="text/plain",
max_bytes=5,
part_size=2,
)
with pytest.raises(UserFileNotFoundError):
await service.preview("uploads/too-large.txt")
@pytest.mark.asyncio
async def test_user_file_service_rejects_root_delete_and_traversal(tmp_path) -> None:
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
with pytest.raises(UserFilePathError):
await service.delete("uploads")
with pytest.raises(UserFilePathError):
await service.upload("../workspace", "hello.txt", b"x", content_type="text/plain")
@pytest.mark.asyncio
async def test_user_file_service_creates_nested_directories(tmp_path) -> None:
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
created = await service.mkdir("tasks/task-123/references")
tasks = await service.browse("tasks/task-123")
assert created["path"] == "tasks/task-123/references"
assert created["type"] == "directory"
assert [item["name"] for item in tasks["items"]] == ["references"]
def test_minio_storage_maps_virtual_paths_under_namespace() -> None:
storage = object.__new__(MinIOUserFileStorage)
storage.config = MinIOStorageConfig(
endpoint="minio.local:9000",
access_key="alice-access",
secret_key="alice-secret",
bucket="beaver-user-files",
namespace="users/alice",
)
assert storage._object_name("uploads/report.pdf") == "users/alice/uploads/report.pdf"
assert storage._object_name("tasks/task-123/result.json") == "users/alice/tasks/task-123/result.json"
assert storage._user_path("users/alice/outputs/summary.md") == "outputs/summary.md"
def test_minio_storage_rejects_paths_that_escape_namespace() -> None:
storage = object.__new__(MinIOUserFileStorage)
storage.config = MinIOStorageConfig(
endpoint="minio.local:9000",
access_key="alice-access",
secret_key="alice-secret",
bucket="beaver-user-files",
namespace="users/alice",
)
with pytest.raises(UserFilePathError):
storage._object_name("uploads/../state/config.json")
with pytest.raises(UserFilePathError):
storage._user_path("users/bob/uploads/secret.txt")

View File

@ -0,0 +1,177 @@
from __future__ import annotations
import json
import pytest
from beaver.foundation.config.schema import AuthzConfig, BackendIdentityConfig, BeaverConfig
from beaver.tools.base import ObjectBackedTool, ToolContext
from beaver.tools.builtins import (
UserFilesCopyToWorkspaceTool,
UserFilesListTool,
UserFilesPublishOutputTool,
UserFilesReadTool,
UserFilesWriteTool,
)
@pytest.mark.asyncio
async def test_user_file_tools_write_read_and_list(tmp_path) -> None:
context = ToolContext(workspace=str(tmp_path))
write = ObjectBackedTool(UserFilesWriteTool())
read = ObjectBackedTool(UserFilesReadTool())
list_files = ObjectBackedTool(UserFilesListTool())
written = await write.invoke(
{"path": "outputs/summary.md", "content": "# Summary", "content_type": "text/markdown"},
context,
)
listed = await list_files.invoke({"path": "outputs"}, context)
loaded = await read.invoke({"path": "outputs/summary.md"}, context)
assert written.success is True
assert json.loads(written.content)["path"] == "outputs/summary.md"
assert listed.success is True
assert [item["name"] for item in json.loads(listed.content)["items"]] == ["summary.md"]
assert loaded.success is True
assert json.loads(loaded.content)["content"] == "# Summary"
@pytest.mark.asyncio
async def test_user_file_tools_reject_agent_write_to_uploads(tmp_path) -> None:
context = ToolContext(workspace=str(tmp_path))
write = ObjectBackedTool(UserFilesWriteTool())
result = await write.invoke({"path": "uploads/notes.txt", "content": "changed"}, context)
assert result.success is False
assert "uploads/ is user-provided input storage" in (result.error or "")
@pytest.mark.asyncio
async def test_user_file_tools_enforce_current_task_namespace(tmp_path) -> None:
context = ToolContext(workspace=str(tmp_path), services={"task_id": "task-123"})
write = ObjectBackedTool(UserFilesWriteTool())
current = await write.invoke({"path": "tasks/task-123/drafts/notes.md", "content": "ok"}, context)
direct = await write.invoke({"path": "tasks/notes.md", "content": "bad"}, context)
other = await write.invoke({"path": "tasks/task-456/notes.md", "content": "bad"}, context)
assert current.success is True
assert direct.success is False
assert "tasks/task-123/" in (direct.error or "")
assert other.success is False
assert "tasks/task-123/" in (other.error or "")
@pytest.mark.asyncio
async def test_user_file_tools_allow_shared_context_write(tmp_path) -> None:
context = ToolContext(workspace=str(tmp_path), services={"task_id": "task-123"})
write = ObjectBackedTool(UserFilesWriteTool())
read = ObjectBackedTool(UserFilesReadTool())
written = await write.invoke({"path": "shared/profile.json", "content": "{\"name\":\"Alice\"}"}, context)
loaded = await read.invoke({"path": "shared/profile.json"}, context)
assert written.success is True
assert loaded.success is True
assert json.loads(loaded.content)["content"] == "{\"name\":\"Alice\"}"
@pytest.mark.asyncio
async def test_user_file_tools_copy_to_workspace_and_publish_output(tmp_path) -> None:
uploads_dir = tmp_path / "user_files" / "uploads"
uploads_dir.mkdir(parents=True)
(uploads_dir / "get_helm.sh").write_text(": ${USE_SUDO:=\"true\"}\n", encoding="utf-8")
context = ToolContext(
workspace=str(tmp_path),
services={"task_id": "task-123"},
metadata={"run_id": "run-1"},
)
copy_tool = ObjectBackedTool(UserFilesCopyToWorkspaceTool())
publish_tool = ObjectBackedTool(UserFilesPublishOutputTool())
read = ObjectBackedTool(UserFilesReadTool())
copied = await copy_tool.invoke({"path": "uploads/get_helm.sh"}, context)
copied_payload = json.loads(copied.content)
staged = tmp_path / copied_payload["workspace_path"]
staged.write_text(": ${USE_SUDO:=\"false\"}\n", encoding="utf-8")
published = await publish_tool.invoke(
{"source_path": copied_payload["workspace_path"], "target_path": "outputs/get_helm.no-sudo.sh"},
context,
)
original = await read.invoke({"path": "uploads/get_helm.sh"}, context)
output = await read.invoke({"path": "outputs/get_helm.no-sudo.sh"}, context)
assert copied.success is True
assert copied_payload["workspace_path"] == "user-files/tasks/task-123/get_helm.sh"
assert published.success is True
assert json.loads(original.content)["content"] == ": ${USE_SUDO:=\"true\"}\n"
assert json.loads(output.content)["content"] == ": ${USE_SUDO:=\"false\"}\n"
@pytest.mark.asyncio
async def test_user_file_publish_rejects_non_output_target_and_workspace_escape(tmp_path) -> None:
context = ToolContext(workspace=str(tmp_path))
source = tmp_path / "result.txt"
source.write_text("done", encoding="utf-8")
outside = tmp_path.parent / "outside.txt"
outside.write_text("outside", encoding="utf-8")
publish_tool = ObjectBackedTool(UserFilesPublishOutputTool())
upload_target = await publish_tool.invoke({"source_path": "result.txt", "target_path": "uploads/result.txt"}, context)
escaped_source = await publish_tool.invoke({"source_path": str(outside), "target_path": "outputs/result.txt"}, context)
assert upload_target.success is False
assert "outputs/" in (upload_target.error or "")
assert escaped_source.success is False
assert "escapes workspace" in (escaped_source.error or "")
@pytest.mark.asyncio
async def test_user_file_tools_reject_internal_workspace_paths(tmp_path) -> None:
context = ToolContext(workspace=str(tmp_path))
read = ObjectBackedTool(UserFilesReadTool())
write = ObjectBackedTool(UserFilesWriteTool())
read_result = await read.invoke({"path": "uploads/../../state/secrets.json"}, context)
write_result = await write.invoke({"path": "workspace/debug.txt", "content": "x"}, context)
assert read_result.success is False
assert "Parent-directory traversal" in read_result.error
assert write_result.success is False
assert "Path must be under" in write_result.error
@pytest.mark.asyncio
async def test_user_file_tools_reject_absolute_style_user_paths(tmp_path) -> None:
context = ToolContext(workspace=str(tmp_path), services={"task_id": "task-123"})
read = ObjectBackedTool(UserFilesReadTool())
write = ObjectBackedTool(UserFilesWriteTool())
list_files = ObjectBackedTool(UserFilesListTool())
read_result = await read.invoke({"path": "/uploads/input.txt"}, context)
write_result = await write.invoke({"path": "/outputs/result.txt", "content": "x"}, context)
task_write = await write.invoke({"path": "/tasks/task-123/draft.md", "content": "x"}, context)
list_result = await list_files.invoke({"path": "/shared/profile.json"}, context)
for result in (read_result, write_result, task_write, list_result):
assert result.success is False
assert "Absolute paths are not allowed" in (result.error or "")
@pytest.mark.asyncio
async def test_user_file_tools_report_missing_deployed_minio_settings(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("BEAVER_AUTHZ_INTERNAL_TOKEN", raising=False)
monkeypatch.delenv("AUTHZ_INTERNAL_TOKEN", raising=False)
config = BeaverConfig(
authz=AuthzConfig(enabled=True, base_url="http://authz.local"),
backend_identity=BackendIdentityConfig(backend_id="alice", client_id="alice", client_secret="secret"),
)
context = ToolContext(workspace=str(tmp_path), services={"beaver_config": config})
write = ObjectBackedTool(UserFilesWriteTool())
result = await write.invoke({"path": "outputs/summary.md", "content": "# Summary"}, context)
assert result.success is False
assert "AuthZ internal token is not configured" in (result.error or "")

View File

@ -6,6 +6,14 @@ from fastapi.testclient import TestClient
from beaver.interfaces.web.app import create_app from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService from beaver.services.agent_service import AgentService
from beaver.services.user_file_resolver import UserFileStorageResolver
from beaver.services.user_files import LocalUserFileStorage, UserFileService
def _auth_headers(app, username: str = "alice") -> dict[str, str]:
token = f"test-token-{username}"
app.state.auth_tokens[token] = username
return {"Authorization": f"Bearer {token}"}
def test_workspace_browser_api_manages_workspace_files(tmp_path: Path) -> None: def test_workspace_browser_api_manages_workspace_files(tmp_path: Path) -> None:
@ -68,3 +76,145 @@ def test_attachment_file_api_round_trips_uploaded_file(tmp_path: Path) -> None:
assert deleted.status_code == 200 assert deleted.status_code == 200
assert deleted.json() == {"ok": True} assert deleted.json() == {"ok": True}
assert missing.status_code == 404 assert missing.status_code == 404
def test_user_files_api_uses_virtual_roots_and_hides_workspace(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
headers = _auth_headers(app)
root = client.get("/api/user-files/browse", headers=headers)
status = client.get("/api/user-files/status", headers=headers)
upload = client.post(
"/api/user-files/upload",
data={"path": "uploads"},
files={"file": ("hello.txt", b"hello user files", "text/plain")},
headers=headers,
)
uploads = client.get("/api/user-files/browse", params={"path": "uploads"}, headers=headers)
preview = client.get("/api/user-files/preview", params={"path": "uploads/hello.txt"}, headers=headers)
download = client.get("/api/user-files/download", params={"path": "uploads/hello.txt"}, headers=headers)
assert root.status_code == 200
assert [item["name"] for item in root.json()["items"]] == ["uploads", "outputs", "shared", "tasks"]
assert all("bucket" not in item for item in root.json()["items"])
assert status.status_code == 200
assert status.json()["workspace_visible"] is False
assert "base_path" not in status.json()
assert upload.status_code == 200
assert upload.json()["path"] == "uploads/hello.txt"
assert uploads.status_code == 200
assert [item["name"] for item in uploads.json()["items"]] == ["hello.txt"]
assert preview.status_code == 200
assert preview.json()["content"] == "hello user files"
assert download.status_code == 200
assert download.content == b"hello user files"
def test_user_files_api_rejects_invalid_paths_and_root_delete(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
headers = _auth_headers(app)
traversal = client.get("/api/user-files/browse", params={"path": "uploads/../state"}, headers=headers)
unknown_root = client.get("/api/user-files/browse", params={"path": "memory/private.txt"}, headers=headers)
absolute_browse = client.get("/api/user-files/browse", params={"path": "/uploads/input.txt"}, headers=headers)
absolute_download = client.get("/api/user-files/download", params={"path": "/outputs/result.txt"}, headers=headers)
absolute_preview = client.get("/api/user-files/preview", params={"path": "/shared/profile.json"}, headers=headers)
absolute_mkdir = client.post("/api/user-files/mkdir", params={"path": "/tasks/task-123/draft.md"}, headers=headers)
absolute_upload = client.post(
"/api/user-files/upload",
data={"path": "/uploads"},
files={"file": ("input.txt", b"x", "text/plain")},
headers=headers,
)
delete_root = client.delete("/api/user-files/delete", params={"path": "uploads"}, headers=headers)
assert traversal.status_code == 400
assert unknown_root.status_code == 400
assert absolute_browse.status_code == 400
assert absolute_download.status_code == 400
assert absolute_preview.status_code == 400
assert absolute_mkdir.status_code == 400
assert absolute_upload.status_code == 400
assert delete_root.status_code == 400
def test_user_files_api_rejects_anonymous_access_before_storage(tmp_path: Path) -> None:
service = AgentService(workspace=tmp_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
browse = client.get("/api/user-files/browse")
status = client.get("/api/user-files/status")
upload = client.post(
"/api/user-files/upload",
data={"path": "uploads"},
files={"file": ("hello.txt", b"hello user files", "text/plain")},
)
delete = client.delete("/api/user-files/delete", params={"path": "uploads/hello.txt"})
mkdir = client.post("/api/user-files/mkdir", params={"path": "uploads/new"})
assert browse.status_code == 401
assert status.status_code == 401
assert upload.status_code == 401
assert delete.status_code == 401
assert mkdir.status_code == 401
def test_user_files_api_authenticated_request_resolves_identity(tmp_path: Path, monkeypatch) -> None:
service = AgentService(workspace=tmp_path)
app = create_app(service=service, manage_service_lifecycle=False)
seen = []
async def fake_service(self):
seen.append(self.auth_context)
return UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
monkeypatch.setattr(UserFileStorageResolver, "service", fake_service)
with TestClient(app) as client:
alice_headers = _auth_headers(app, "alice")
upload = client.post(
"/api/user-files/upload",
data={"path": "uploads"},
files={"file": ("alice.txt", b"alice", "text/plain")},
headers=alice_headers,
)
assert upload.status_code == 200
assert seen
assert seen[0].username == "alice"
assert seen[0].backend_id == "alice"
assert seen[0].storage_namespace == "users/alice"
def test_user_files_api_streams_upload_and_enforces_configured_limit(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", "5")
service = AgentService(workspace=tmp_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
headers = _auth_headers(app)
ok_upload = client.post(
"/api/user-files/upload",
data={"path": "uploads"},
files={"file": ("small.txt", b"abcde", "text/plain")},
headers=headers,
)
too_large = client.post(
"/api/user-files/upload",
data={"path": "uploads"},
files={"file": ("large.txt", b"abcdef", "text/plain")},
headers=headers,
)
preview = client.get("/api/user-files/preview", params={"path": "uploads/small.txt"}, headers=headers)
assert ok_upload.status_code == 200
assert ok_upload.json()["path"] == "uploads/small.txt"
assert too_large.status_code == 413
assert "File too large" in too_large.json()["detail"]
assert preview.status_code == 200
assert preview.json()["content"] == "abcde"

View File

@ -192,6 +192,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
] ]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
]
[[package]] [[package]]
name = "attrs" name = "attrs"
version = "26.1.0" version = "26.1.0"
@ -244,6 +287,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "json-repair" }, { name = "json-repair" },
{ name = "litellm" }, { name = "litellm" },
{ name = "minio" },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-multipart" }, { name = "python-multipart" },
@ -265,6 +309,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0.0" },
{ name = "json-repair", specifier = ">=0.39.0,<1.0.0" }, { name = "json-repair", specifier = ">=0.39.0,<1.0.0" },
{ name = "litellm", specifier = ">=1.79.0,<2.0.0" }, { name = "litellm", specifier = ">=1.79.0,<2.0.0" },
{ name = "minio", specifier = ">=7.2.0,<8.0.0" },
{ name = "openai", specifier = ">=1.79.0,<2.0.0" }, { name = "openai", specifier = ">=1.79.0,<2.0.0" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
@ -1420,6 +1465,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
] ]
[[package]]
name = "minio"
version = "7.2.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi" },
{ name = "certifi" },
{ name = "pycryptodome" },
{ name = "typing-extensions" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" },
]
[[package]] [[package]]
name = "more-itertools" name = "more-itertools"
version = "11.0.2" version = "11.0.2"
@ -1759,6 +1820,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
] ]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.3" version = "2.13.3"

View File

@ -15,8 +15,10 @@ CONTAINER_NAME=""
HOST_PORT="" HOST_PORT=""
PUBLIC_URL="" PUBLIC_URL=""
AUTHZ_BASE_URL="" AUTHZ_BASE_URL=""
AUTHZ_INTERNAL_TOKEN=""
AUTHZ_OUTLOOK_MCP_URL="" AUTHZ_OUTLOOK_MCP_URL=""
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}" OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}"
BACKEND_ID="" BACKEND_ID=""
CLIENT_ID="" CLIENT_ID=""
CLIENT_SECRET="" CLIENT_SECRET=""
@ -61,10 +63,14 @@ Optional:
--model <name> Model name. Default: openai/gpt-5 --model <name> Model name. Default: openai/gpt-5
--skip-provider-config Create the instance without model/provider/API key settings. --skip-provider-config Create the instance without model/provider/API key settings.
--authz-base-url <url> AuthZ service base URL. --authz-base-url <url> AuthZ service base URL.
--authz-internal-token <token>
AuthZ internal token for backend-only user file storage settings lookup.
--authz-outlook-mcp-url <url> --authz-outlook-mcp-url <url>
Managed Outlook MCP URL for AuthZ mode. Managed Outlook MCP URL for AuthZ mode.
--outlook-mcp-server-id <id> --outlook-mcp-server-id <id>
Default Outlook MCP server id. Default: outlook_mcp Default Outlook MCP server id. Default: outlook_mcp
--user-files-max-upload-bytes <bytes>
Optional max upload size for the user file system.
--backend-id <id> Pre-assigned backend id. --backend-id <id> Pre-assigned backend id.
--client-id <id> Pre-assigned AuthZ client id. --client-id <id> Pre-assigned AuthZ client id.
--client-secret <secret> Pre-assigned AuthZ client secret. --client-secret <secret> Pre-assigned AuthZ client secret.
@ -138,6 +144,7 @@ render_config_json() {
API_BASE="$API_BASE" \ API_BASE="$API_BASE" \
SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \ SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \ AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \ AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \ OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
BACKEND_ID="$BACKEND_ID" \ BACKEND_ID="$BACKEND_ID" \
@ -260,6 +267,7 @@ render_runtime_env_file() {
TARGET_PATH="$target_path" \ TARGET_PATH="$target_path" \
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \ AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \ AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
BACKEND_ID="$BACKEND_ID" \ BACKEND_ID="$BACKEND_ID" \
CLIENT_ID="$CLIENT_ID" \ CLIENT_ID="$CLIENT_ID" \
@ -275,6 +283,7 @@ target = Path(os.environ["TARGET_PATH"])
values = { values = {
"BEAVER_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0", "BEAVER_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0",
"BEAVER_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(), "BEAVER_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(),
"BEAVER_AUTHZ_INTERNAL_TOKEN": os.environ["AUTHZ_INTERNAL_TOKEN"].strip(),
"BEAVER_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(), "BEAVER_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
"BEAVER_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(), "BEAVER_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(),
"BEAVER_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(), "BEAVER_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(),
@ -285,6 +294,7 @@ values = {
ordered_keys = [ ordered_keys = [
"BEAVER_AUTHZ__ENABLED", "BEAVER_AUTHZ__ENABLED",
"BEAVER_AUTHZ__BASE_URL", "BEAVER_AUTHZ__BASE_URL",
"BEAVER_AUTHZ_INTERNAL_TOKEN",
"BEAVER_AUTHZ__OUTLOOK_MCP_URL", "BEAVER_AUTHZ__OUTLOOK_MCP_URL",
"BEAVER_BACKEND_IDENTITY__BACKEND_ID", "BEAVER_BACKEND_IDENTITY__BACKEND_ID",
"BEAVER_BACKEND_IDENTITY__CLIENT_ID", "BEAVER_BACKEND_IDENTITY__CLIENT_ID",
@ -380,6 +390,10 @@ while [[ $# -gt 0 ]]; do
AUTHZ_BASE_URL="${2:-}" AUTHZ_BASE_URL="${2:-}"
shift 2 shift 2
;; ;;
--authz-internal-token)
AUTHZ_INTERNAL_TOKEN="${2:-}"
shift 2
;;
--authz-outlook-mcp-url) --authz-outlook-mcp-url)
AUTHZ_OUTLOOK_MCP_URL="${2:-}" AUTHZ_OUTLOOK_MCP_URL="${2:-}"
shift 2 shift 2
@ -388,6 +402,10 @@ while [[ $# -gt 0 ]]; do
OUTLOOK_MCP_SERVER_ID="${2:-}" OUTLOOK_MCP_SERVER_ID="${2:-}"
shift 2 shift 2
;; ;;
--user-files-max-upload-bytes)
USER_FILES_MAX_UPLOAD_BYTES="${2:-}"
shift 2
;;
--backend-id) --backend-id)
BACKEND_ID="${2:-}" BACKEND_ID="${2:-}"
shift 2 shift 2
@ -570,6 +588,10 @@ RUN_ARGS=(
--label "beaver.instance.public_url=${PUBLIC_URL}" --label "beaver.instance.public_url=${PUBLIC_URL}"
) )
if [[ -n "$USER_FILES_MAX_UPLOAD_BYTES" ]]; then
RUN_ARGS+=(-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES}")
fi
if [[ -n "$NETWORK_NAME" ]]; then if [[ -n "$NETWORK_NAME" ]]; then
RUN_ARGS+=(--network "$NETWORK_NAME") RUN_ARGS+=(--network "$NETWORK_NAME")
fi fi

View File

@ -4,6 +4,9 @@ set -euo pipefail
APP_PUBLIC_PORT="${APP_PUBLIC_PORT:-8080}" APP_PUBLIC_PORT="${APP_PUBLIC_PORT:-8080}"
APP_FRONTEND_PORT="${APP_FRONTEND_PORT:-3000}" APP_FRONTEND_PORT="${APP_FRONTEND_PORT:-3000}"
APP_BACKEND_PORT="${APP_BACKEND_PORT:-18080}" APP_BACKEND_PORT="${APP_BACKEND_PORT:-18080}"
UVICORN_LOOP="${UVICORN_LOOP:-asyncio}"
UVICORN_HTTP="${UVICORN_HTTP:-h11}"
UVICORN_WS="${UVICORN_WS:-websockets}"
BEAVER_HOME="${BEAVER_HOME:-/root/.beaver}" BEAVER_HOME="${BEAVER_HOME:-/root/.beaver}"
BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}" BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}"
BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}" BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}"
@ -59,11 +62,12 @@ export BEAVER_CONFIG_PATH
export BEAVER_WORKSPACE export BEAVER_WORKSPACE
export PORT="$APP_FRONTEND_PORT" export PORT="$APP_FRONTEND_PORT"
export HOSTNAME="127.0.0.1" export HOSTNAME="127.0.0.1"
export PYTHONFAULTHANDLER="${PYTHONFAULTHANDLER:-1}"
log "starting Beaver backend on 127.0.0.1:${APP_BACKEND_PORT}" log "starting Beaver backend on 127.0.0.1:${APP_BACKEND_PORT} (loop=${UVICORN_LOOP}, http=${UVICORN_HTTP}, ws=${UVICORN_WS})"
( (
cd /opt/app/backend cd /opt/app/backend
python -m uvicorn "beaver.interfaces.web.app:create_app" --factory --host 127.0.0.1 --port "$APP_BACKEND_PORT" python -m uvicorn "beaver.interfaces.web.app:create_app" --factory --host 127.0.0.1 --port "$APP_BACKEND_PORT" --loop "$UVICORN_LOOP" --http "$UVICORN_HTTP" --ws "$UVICORN_WS"
) & ) &
BACKEND_PID=$! BACKEND_PID=$!

View File

@ -21,49 +21,71 @@ import {
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { import {
browseWorkspace, browseUserFiles,
getWorkspaceFile, getUserFile,
getWorkspaceDownloadUrl, getUserFileDownloadUrl,
uploadToWorkspace, uploadUserFile,
deleteWorkspacePath, deleteUserFile,
createWorkspaceDir, createUserFileDir,
getAccessToken, getAccessToken,
} from '@/lib/api'; } from '@/lib/api';
import type { WorkspaceFileContent, WorkspaceItem } from '@/lib/api'; import type { UserFileContent, UserFileItem } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { type AppLocale, pickAppText } from '@/lib/i18n/core'; import { type AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider'; import { useAppI18n } from '@/lib/i18n/provider';
const LOAD_RETRY_DELAYS_MS = [0, 600, 1200];
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
export default function FilesPage() { export default function FilesPage() {
const { locale } = useAppI18n(); const { locale } = useAppI18n();
const [items, setItems] = useState<WorkspaceItem[]>([]); const [items, setItems] = useState<UserFileItem[]>([]);
const [currentPath, setCurrentPath] = useState(''); const [currentPath, setCurrentPath] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [showMkdir, setShowMkdir] = useState(false); const [showMkdir, setShowMkdir] = useState(false);
const [newDirName, setNewDirName] = useState(''); const [newDirName, setNewDirName] = useState('');
const [selectedFile, setSelectedFile] = useState<WorkspaceFileContent | null>(null); const [selectedFile, setSelectedFile] = useState<UserFileContent | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null); const [previewError, setPreviewError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const mkdirInputRef = useRef<HTMLInputElement>(null); const mkdirInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async (path: string = currentPath) => { const load = useCallback(async (path: string = currentPath) => {
let lastError: unknown = null;
try { try {
setLoading(true); setLoading(true);
const data = await browseWorkspace(path); setLoadError(null);
setItems(data.items); for (const delay of LOAD_RETRY_DELAYS_MS) {
setCurrentPath(data.path); if (delay > 0) {
setSelectedFile(null); await sleep(delay);
setPreviewError(null); }
} catch { try {
// ignore const data = await browseUserFiles(path);
setItems(data.items);
setCurrentPath(data.path);
setSelectedFile(null);
setPreviewError(null);
return;
} catch (err) {
lastError = err;
}
}
const message = lastError instanceof Error ? lastError.message : pickAppText(locale, '加载文件失败', 'Failed to load files');
setLoadError(message);
setItems([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPath]); }, [currentPath, locale]);
useEffect(() => { useEffect(() => {
load(''); load('');
@ -73,12 +95,12 @@ export default function FilesPage() {
load(path); load(path);
}; };
const openFile = async (item: WorkspaceItem) => { const openFile = async (item: UserFileItem) => {
if (item.type !== 'file') return; if (item.type !== 'file') return;
setPreviewLoading(true); setPreviewLoading(true);
setPreviewError(null); setPreviewError(null);
try { try {
setSelectedFile(await getWorkspaceFile(item.path)); setSelectedFile(await getUserFile(item.path));
} catch (err: any) { } catch (err: any) {
setPreviewError(err.message || pickAppText(locale, '加载文件失败', 'Failed to load file')); setPreviewError(err.message || pickAppText(locale, '加载文件失败', 'Failed to load file'));
setSelectedFile(null); setSelectedFile(null);
@ -87,7 +109,7 @@ export default function FilesPage() {
} }
}; };
const handleDelete = async (item: WorkspaceItem) => { const handleDelete = async (item: UserFileItem) => {
const label = item.type === 'directory' const label = item.type === 'directory'
? pickAppText(locale, '文件夹', 'folder') ? pickAppText(locale, '文件夹', 'folder')
: pickAppText(locale, '文件', 'file'); : pickAppText(locale, '文件', 'file');
@ -99,7 +121,7 @@ export default function FilesPage() {
return; return;
} }
try { try {
await deleteWorkspacePath(item.path); await deleteUserFile(item.path);
setItems((prev) => prev.filter((i) => i.path !== item.path)); setItems((prev) => prev.filter((i) => i.path !== item.path));
if (selectedFile?.path === item.path) { if (selectedFile?.path === item.path) {
setSelectedFile(null); setSelectedFile(null);
@ -109,8 +131,8 @@ export default function FilesPage() {
} }
}; };
const handleDownload = async (item: WorkspaceItem) => { const handleDownload = async (item: UserFileItem) => {
const url = getWorkspaceDownloadUrl(item.path); const url = getUserFileDownloadUrl(item.path);
const token = getAccessToken(); const token = getAccessToken();
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
@ -138,7 +160,7 @@ export default function FilesPage() {
setUploadProgress(0); setUploadProgress(0);
try { try {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
await uploadToWorkspace(files[i], currentPath, (pct) => { await uploadUserFile(files[i], currentPath || 'uploads', (pct) => {
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length)); setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
}); });
} }
@ -157,7 +179,7 @@ export default function FilesPage() {
if (!name) return; if (!name) return;
try { try {
const dirPath = currentPath ? `${currentPath}/${name}` : name; const dirPath = currentPath ? `${currentPath}/${name}` : name;
await createWorkspaceDir(dirPath); await createUserFileDir(dirPath);
setShowMkdir(false); setShowMkdir(false);
setNewDirName(''); setNewDirName('');
await load(); await load();
@ -176,7 +198,8 @@ export default function FilesPage() {
return `${bytes} B`; return `${bytes} B`;
}; };
const formatDate = (iso: string) => { const formatDate = (iso: string | null | undefined) => {
if (!iso) return '';
try { try {
return new Date(iso).toLocaleString(locale, { return new Date(iso).toLocaleString(locale, {
month: '2-digit', month: '2-digit',
@ -199,7 +222,7 @@ export default function FilesPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowMkdir(true)} onClick={() => setShowMkdir(true)}
disabled={loading} disabled={loading || !currentPath}
> >
<FolderPlus className="w-4 h-4 mr-1" /> <FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')} {pickAppText(locale, '新建文件夹', 'New folder')}
@ -208,7 +231,7 @@ export default function FilesPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploading} disabled={uploading || !currentPath}
> >
{uploading ? ( {uploading ? (
<> <>
@ -246,7 +269,7 @@ export default function FilesPage() {
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent" className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
> >
<Home className="w-3.5 h-3.5" /> <Home className="w-3.5 h-3.5" />
{pickAppText(locale, '工作区', 'Workspace')} {pickAppText(locale, '文件', 'Files')}
</button> </button>
{breadcrumbs.map((segment, idx) => { {breadcrumbs.map((segment, idx) => {
const path = breadcrumbs.slice(0, idx + 1).join('/'); const path = breadcrumbs.slice(0, idx + 1).join('/');
@ -312,6 +335,16 @@ export default function FilesPage() {
<div className="flex items-center justify-center py-20 text-muted-foreground"> <div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" /> <Loader2 className="w-6 h-6 animate-spin" />
</div> </div>
) : loadError ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '加载失败', 'Failed to load')}</p>
<p className="max-w-sm text-center text-sm">{loadError}</p>
<Button className="mt-4" variant="outline" size="sm" onClick={() => load()}>
<RefreshCw className="mr-1 h-4 w-4" />
{pickAppText(locale, '重试', 'Retry')}
</Button>
</div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" /> <FolderOpen className="w-12 h-12 mb-4 opacity-50" />
@ -340,7 +373,7 @@ export default function FilesPage() {
{item.type === 'directory' ? ( {item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" /> <Folder className="w-5 h-5 text-blue-500" />
) : ( ) : (
<FileIcon name={item.name} contentType={item.content_type} /> <FileIcon name={item.name} contentType={item.content_type || undefined} />
)} )}
</div> </div>
@ -412,7 +445,7 @@ export default function FilesPage() {
error={previewError} error={previewError}
formatSize={formatSize} formatSize={formatSize}
formatDate={formatDate} formatDate={formatDate}
downloadUrl={selectedFile ? getWorkspaceDownloadUrl(selectedFile.path) : null} downloadUrl={selectedFile ? getUserFileDownloadUrl(selectedFile.path) : null}
locale={locale} locale={locale}
/> />
</div> </div>
@ -429,11 +462,11 @@ function FilePreviewPanel({
downloadUrl, downloadUrl,
locale, locale,
}: { }: {
file: WorkspaceFileContent | null; file: UserFileContent | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
formatSize: (bytes: number | null) => string; formatSize: (bytes: number | null) => string;
formatDate: (iso: string) => string; formatDate: (iso: string | null | undefined) => string;
downloadUrl: string | null; downloadUrl: string | null;
locale: AppLocale; locale: AppLocale;
}) { }) {
@ -516,10 +549,10 @@ function FileIcon({ name, contentType }: { name: string; contentType?: string })
return <FileText className="w-5 h-5 text-muted-foreground" />; return <FileText className="w-5 h-5 text-muted-foreground" />;
} }
function isImage(file: WorkspaceFileContent): boolean { function isImage(file: UserFileContent): boolean {
return file.content_type.startsWith('image/'); return file.content_type.startsWith('image/');
} }
function isMarkdown(file: WorkspaceFileContent): boolean { function isMarkdown(file: UserFileContent): boolean {
return file.path.toLowerCase().endsWith('.md') || file.content_type.includes('markdown'); return file.path.toLowerCase().endsWith('.md') || file.content_type.includes('markdown');
} }

View File

@ -97,6 +97,16 @@ function transportLabel(transport: string | undefined, locale: AppLocale) {
return transport || '-'; return transport || '-';
} }
function discoveredToolCount(
serverId: string,
tools: Array<{ server_id: string; tools: Array<Record<string, unknown>> }>,
fallback?: number,
) {
const group = tools.find((item) => item.server_id === serverId);
if (group) return group.tools.length;
return fallback || 0;
}
export default function MCPPage() { export default function MCPPage() {
const { locale } = useAppI18n(); const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en); const t = (zh: string, en: string) => pickAppText(locale, zh, en);
@ -543,7 +553,7 @@ export default function MCPPage() {
<div><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div> <div><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)} )}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground"> <div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{t(`${server.tool_count || 0} 个工具`, `${server.tool_count || 0} tools`)}</span> <span>{t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} tools`)}</span>
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span> <span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
{server.last_error && <span className="text-rose-300">{server.last_error}</span>} {server.last_error && <span className="text-rose-300">{server.last_error}</span>}
</div> </div>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { usePathname } from 'next/navigation';
import { getStatus, listSessions, wsManager } from '@/lib/api'; import { getStatus, listSessions, wsManager } from '@/lib/api';
import { useChatStore } from '@/lib/store'; import { useChatStore } from '@/lib/store';
@ -37,6 +38,7 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is
} }
export function AppRuntimeBridge() { export function AppRuntimeBridge() {
const pathname = usePathname();
const sessionId = useChatStore((state) => state.sessionId); const sessionId = useChatStore((state) => state.sessionId);
const setSessions = useChatStore((state) => state.setSessions); const setSessions = useChatStore((state) => state.setSessions);
const setWsStatus = useChatStore((state) => state.setWsStatus); const setWsStatus = useChatStore((state) => state.setWsStatus);
@ -45,6 +47,7 @@ export function AppRuntimeBridge() {
const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent); const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent);
const statusCheckCleanupRef = React.useRef<(() => void) | null>(null); const statusCheckCleanupRef = React.useRef<(() => void) | null>(null);
const statusCheckInFlightRef = React.useRef(false); const statusCheckInFlightRef = React.useRef(false);
const chatRuntimeEnabled = pathname === '/' || pathname.startsWith('/tasks') || pathname.startsWith('/notifications');
const loadSessions = React.useCallback(async () => { const loadSessions = React.useCallback(async () => {
try { try {
@ -73,15 +76,27 @@ export function AppRuntimeBridge() {
}, [setBeaverReady]); }, [setBeaverReady]);
React.useEffect(() => { React.useEffect(() => {
if (!chatRuntimeEnabled) {
return;
}
void loadSessions(); void loadSessions();
}, [loadSessions]); }, [chatRuntimeEnabled, loadSessions]);
React.useEffect(() => { React.useEffect(() => {
if (!chatRuntimeEnabled) {
wsManager.disconnect();
setWsStatus('disconnected');
setBeaverReady(null);
return;
}
resetProcessState(); resetProcessState();
wsManager.connect(sessionId); wsManager.connect(sessionId);
}, [resetProcessState, sessionId]); }, [chatRuntimeEnabled, resetProcessState, sessionId, setBeaverReady, setWsStatus]);
React.useEffect(() => { React.useEffect(() => {
if (!chatRuntimeEnabled) {
return;
}
const unsubStatus = wsManager.onStatusChange((status) => { const unsubStatus = wsManager.onStatusChange((status) => {
setWsStatus(status); setWsStatus(status);
if (status === 'connected') { if (status === 'connected') {
@ -98,9 +113,12 @@ export function AppRuntimeBridge() {
statusCheckCleanupRef.current = null; statusCheckCleanupRef.current = null;
unsubStatus(); unsubStatus();
}; };
}, [scheduleStatusCheck, setBeaverReady, setWsStatus]); }, [chatRuntimeEnabled, scheduleStatusCheck, setBeaverReady, setWsStatus]);
React.useEffect(() => { React.useEffect(() => {
if (!chatRuntimeEnabled) {
return;
}
const unsubMessage = wsManager.onMessage((data) => { const unsubMessage = wsManager.onMessage((data) => {
if (isSessionUpdatedEvent(data)) { if (isSessionUpdatedEvent(data)) {
void loadSessions(); void loadSessions();
@ -115,7 +133,7 @@ export function AppRuntimeBridge() {
return () => { return () => {
unsubMessage(); unsubMessage();
}; };
}, [ingestProcessEvent, loadSessions]); }, [chatRuntimeEnabled, ingestProcessEvent, loadSessions]);
return null; return null;
} }

View File

@ -1363,3 +1363,112 @@ export async function createWorkspaceDir(path: string): Promise<WorkspaceItem> {
method: 'POST', method: 'POST',
}); });
} }
// ---------------------------------------------------------------------------
// User File System
// ---------------------------------------------------------------------------
export interface UserFileItem {
name: string;
path: string;
type: 'file' | 'directory';
size: number | null;
content_type?: string | null;
modified?: string | null;
}
export interface UserFileBrowseResult {
path: string;
items: UserFileItem[];
}
export interface UserFileContent {
name: string;
path: string;
size: number;
content_type: string;
modified: string | null;
is_binary: boolean;
is_truncated: boolean;
content: string | null;
}
export interface UserFilesStatus {
configured: boolean;
storage_mode: string;
roots: string[];
workspace_visible: boolean;
}
export async function getUserFilesStatus(): Promise<UserFilesStatus> {
return fetchJSON('/api/user-files/status');
}
export async function browseUserFiles(path: string = ''): Promise<UserFileBrowseResult> {
const params = path ? `?path=${encodeURIComponent(path)}` : '';
return fetchJSON(`/api/user-files/browse${params}`);
}
export async function getUserFile(path: string): Promise<UserFileContent> {
return fetchJSON(`/api/user-files/preview?path=${encodeURIComponent(path)}`);
}
export function getUserFileDownloadUrl(path: string): string {
return buildApiUrl(`/api/user-files/download?path=${encodeURIComponent(path)}`);
}
export async function uploadUserFile(
file: File,
dirPath: string = 'uploads',
onProgress?: (percent: number) => void
): Promise<UserFileItem> {
const locale = getCurrentAppLocale();
const formData = new FormData();
formData.append('file', file);
formData.append('path', dirPath);
return new Promise<UserFileItem>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', buildApiUrl('/api/user-files/upload'));
const token = getAccessToken();
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
let detail = '';
try {
const data = JSON.parse(xhr.responseText);
detail = typeof data?.detail === 'string' ? data.detail : '';
} catch {
detail = '';
}
reject(new Error(detail || `${pickAppText(locale, '上传失败', 'Upload failed')}: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error(pickAppText(locale, '上传失败', 'Upload failed')));
xhr.send(formData);
});
}
export async function deleteUserFile(path: string): Promise<void> {
await fetchJSON(`/api/user-files/delete?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
}
export async function createUserFileDir(path: string): Promise<UserFileItem> {
return fetchJSON(`/api/user-files/mkdir?path=${encodeURIComponent(path)}`, {
method: 'POST',
});
}

View File

@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = resolve(__dirname, '..');
describe('user file system frontend wiring', () => {
it('routes API client helpers to user file endpoints', () => {
const apiSource = readFileSync(resolve(root, 'lib/api.ts'), 'utf8');
expect(apiSource).toContain('/api/user-files/browse');
expect(apiSource).toContain('/api/user-files/upload');
expect(apiSource).toContain('/api/user-files/download');
expect(apiSource).toContain('/api/user-files/preview');
expect(apiSource).toContain('/api/user-files/delete');
expect(apiSource).toContain('/api/user-files/mkdir');
});
it('does not wire the Files page to workspace or MinIO management APIs', () => {
const pageSource = readFileSync(resolve(root, 'app/(app)/files/page.tsx'), 'utf8');
expect(pageSource).toContain('browseUserFiles');
expect(pageSource).toContain('uploadUserFile');
expect(pageSource).not.toContain('browseWorkspace');
expect(pageSource).not.toContain('uploadToWorkspace');
expect(pageSource).not.toContain('MinIO');
expect(pageSource).not.toContain('bucket');
expect(pageSource).not.toContain('accessKey');
expect(pageSource).not.toContain('secretKey');
});
});

View File

@ -46,6 +46,21 @@ def _normalize_record(record: dict[str, Any]) -> dict[str, Any]:
return normalized return normalized
def read_registry(path: Path) -> dict[str, Any]:
if not path.exists():
return _default_data()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return _default_data()
if not isinstance(data, dict):
return _default_data()
if not isinstance(data.get("instances"), list):
data["instances"] = []
data["instances"] = [_normalize_record(item) for item in data["instances"] if isinstance(item, dict)]
return data
@contextmanager @contextmanager
def locked_registry(path: Path): def locked_registry(path: Path):
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@ -118,8 +133,8 @@ def _get_record(
def cmd_list(args: argparse.Namespace) -> int: def cmd_list(args: argparse.Namespace) -> int:
path = Path(args.registry).expanduser() path = Path(args.registry).expanduser()
with locked_registry(path) as data: data = read_registry(path)
instances = list(data["instances"]) instances = list(data["instances"])
if args.json: if args.json:
json.dump({"instances": instances}, sys.stdout, indent=2, ensure_ascii=False) json.dump({"instances": instances}, sys.stdout, indent=2, ensure_ascii=False)
sys.stdout.write("\n") sys.stdout.write("\n")
@ -143,15 +158,15 @@ def cmd_list(args: argparse.Namespace) -> int:
def cmd_get(args: argparse.Namespace) -> int: def cmd_get(args: argparse.Namespace) -> int:
path = Path(args.registry).expanduser() path = Path(args.registry).expanduser()
with locked_registry(path) as data: data = read_registry(path)
record = _get_record( record = _get_record(
data, data,
instance_id=args.instance_id, instance_id=args.instance_id,
slug=args.slug, slug=args.slug,
container_name=args.container_name, container_name=args.container_name,
username=args.username, username=args.username,
instance_host=args.instance_host, instance_host=args.instance_host,
) )
if record is None: if record is None:
return 1 return 1
json.dump(record, sys.stdout, indent=2, ensure_ascii=False) json.dump(record, sys.stdout, indent=2, ensure_ascii=False)

View File

@ -11,7 +11,7 @@ http {
sendfile on; sendfile on;
tcp_nopush on; tcp_nopush on;
keepalive_timeout 65; keepalive_timeout 65;
client_max_body_size 50m; client_max_body_size 5g;
access_log /dev/stdout; access_log /dev/stdout;
error_log /dev/stderr warn; error_log /dev/stderr warn;
@ -69,4 +69,3 @@ http {
} }
} }
} }

View File

@ -7,7 +7,15 @@ from pathlib import Path
from threading import Lock from threading import Lock
from typing import Any from typing import Any
from app.models import ChannelSettings, BackendCredential, BackendRecord, OutlookSettings, UserRecord, utcnow_iso from app.models import (
ChannelSettings,
BackendCredential,
BackendRecord,
MinIOSettings,
OutlookSettings,
UserRecord,
utcnow_iso,
)
class JsonStore: class JsonStore:
@ -152,6 +160,19 @@ class JsonStore:
return None return None
return OutlookSettings.model_validate(outlook) return OutlookSettings.model_validate(outlook)
def get_minio_settings(self, backend_id: str) -> MinIOSettings | None:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
return None
backend_settings = root.get(backend_id, {})
if not isinstance(backend_settings, dict):
return None
minio = backend_settings.get("minio")
if not isinstance(minio, dict):
return None
return MinIOSettings.model_validate(minio)
def list_channel_settings(self, backend_id: str) -> dict[str, ChannelSettings]: def list_channel_settings(self, backend_id: str) -> dict[str, ChannelSettings]:
raw = self._read_json(self.settings_path, {"settings": {}}) raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {}) root = raw.get("settings", {})
@ -185,6 +206,19 @@ class JsonStore:
self._write_json(self.settings_path, {"settings": root}) self._write_json(self.settings_path, {"settings": root})
return backend_settings return backend_settings
def save_minio_settings(self, backend_id: str, settings: MinIOSettings) -> dict[str, Any]:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
root = {}
backend_settings = root.get(backend_id, {})
if not isinstance(backend_settings, dict):
backend_settings = {}
backend_settings["minio"] = settings.model_dump(mode="json")
root[backend_id] = backend_settings
self._write_json(self.settings_path, {"settings": root})
return backend_settings
def save_channel_settings(self, backend_id: str, channel_id: str, settings: ChannelSettings) -> dict[str, Any]: def save_channel_settings(self, backend_id: str, channel_id: str, settings: ChannelSettings) -> dict[str, Any]:
raw = self._read_json(self.settings_path, {"settings": {}}) raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {}) root = raw.get("settings", {})
@ -218,6 +252,22 @@ class JsonStore:
self._write_json(self.settings_path, {"settings": root}) self._write_json(self.settings_path, {"settings": root})
return True return True
def delete_minio_settings(self, backend_id: str) -> bool:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
return False
backend_settings = root.get(backend_id)
if not isinstance(backend_settings, dict) or "minio" not in backend_settings:
return False
backend_settings.pop("minio", None)
if backend_settings:
root[backend_id] = backend_settings
else:
root.pop(backend_id, None)
self._write_json(self.settings_path, {"settings": root})
return True
def delete_channel_settings(self, backend_id: str, channel_id: str) -> bool: def delete_channel_settings(self, backend_id: str, channel_id: str) -> bool:
raw = self._read_json(self.settings_path, {"settings": {}}) raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {}) root = raw.get("settings", {})

View File

@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Request from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.json_store import JsonStore from app.json_store import JsonStore
@ -16,6 +17,7 @@ from app.models import (
BackendRecord, BackendRecord,
IntrospectRequest, IntrospectRequest,
IntrospectResponse, IntrospectResponse,
MinIOSettings,
OAuthTokenRequest, OAuthTokenRequest,
OAuthTokenResponse, OAuthTokenResponse,
OutlookSettings, OutlookSettings,
@ -30,6 +32,12 @@ from app.models import (
UserRecord, UserRecord,
utcnow_iso, utcnow_iso,
) )
from app.minio_provisioning import (
MinIODeprovisioningError,
MinIOProvisioningError,
deprovision_user_file_minio_resources,
provision_user_file_minio_settings,
)
from app.security import JwtSigner, generate_client_secret, hash_secret, verify_secret from app.security import JwtSigner, generate_client_secret, hash_secret, verify_secret
DATA_DIR = Path(os.getenv("AUTHZ_DATA_DIR", Path(__file__).resolve().parents[1] / "data")) DATA_DIR = Path(os.getenv("AUTHZ_DATA_DIR", Path(__file__).resolve().parents[1] / "data"))
@ -360,6 +368,42 @@ def _upsert_user(
return user return user
async def _ensure_user_file_storage_settings(backend_id: str) -> MinIOSettings | None:
try:
settings = await asyncio.to_thread(
provision_user_file_minio_settings,
backend_id=backend_id,
existing=store.get_minio_settings(backend_id),
)
except MinIOProvisioningError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
if settings is not None:
store.save_minio_settings(backend_id, settings)
store.touch_backend(backend_id)
return settings
async def _deprovision_user_file_storage(backend_id: str, *, best_effort: bool = False) -> dict[str, Any]:
existing = store.get_minio_settings(backend_id)
try:
result = await asyncio.to_thread(
deprovision_user_file_minio_resources,
backend_id=backend_id,
existing=existing,
best_effort=best_effort,
)
except MinIODeprovisioningError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
settings_removed = False
if result.get("ok") is True:
settings_removed = store.delete_minio_settings(backend_id)
if settings_removed:
store.touch_backend(backend_id)
result["settings"] = {"status": "removed" if settings_removed else "absent"}
return result
def _resolve_register_backend_payload(req: RegisterBackendRequest) -> tuple[str, str, str, str | None]: def _resolve_register_backend_payload(req: RegisterBackendRequest) -> tuple[str, str, str, str | None]:
backend_name = req.name.strip() or "backend" backend_name = req.name.strip() or "backend"
backend_id = _clean_optional(req.backend_id) backend_id = _clean_optional(req.backend_id)
@ -475,6 +519,7 @@ async def register_backend(req: RegisterBackendRequest) -> RegisterBackendRespon
base_url=base_url, base_url=base_url,
frontend_base_url=frontend_base_url, frontend_base_url=frontend_base_url,
) )
await _ensure_user_file_storage_settings(backend.backend_id)
return RegisterBackendResponse( return RegisterBackendResponse(
backend_id=backend.backend_id, backend_id=backend.backend_id,
client_id=backend.backend_id, client_id=backend.backend_id,
@ -589,6 +634,39 @@ async def delete_outlook_settings(backend_id: str) -> dict[str, Any]:
return {"ok": removed} return {"ok": removed}
@app.get("/backends/{backend_id}/settings/minio")
async def get_minio_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
settings = store.get_minio_settings(backend_id)
if settings is None:
return {"configured": False}
return settings.masked_dict()
@app.post("/backends/{backend_id}/settings/minio")
async def save_minio_settings(backend_id: str, payload: MinIOSettings) -> dict[str, Any]:
_ensure_backend(backend_id)
existing = store.get_minio_settings(backend_id)
if not payload.secret_key:
if existing is None or not existing.secret_key:
raise HTTPException(status_code=400, detail="Secret key is required for initial MinIO setup")
payload = payload.model_copy(update={"secret_key": existing.secret_key, "updated_at": utcnow_iso()})
else:
payload = payload.model_copy(update={"updated_at": utcnow_iso()})
store.save_minio_settings(backend_id, payload)
store.touch_backend(backend_id)
return payload.masked_dict()
@app.delete("/backends/{backend_id}/settings/minio")
async def delete_minio_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
removed = store.delete_minio_settings(backend_id)
if removed:
store.touch_backend(backend_id)
return {"ok": removed}
@app.get("/backends/{backend_id}/settings/channels") @app.get("/backends/{backend_id}/settings/channels")
async def list_channel_settings(backend_id: str) -> dict[str, Any]: async def list_channel_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id) _ensure_backend(backend_id)
@ -662,6 +740,7 @@ async def oauth_register(req: RegisterUserRequest) -> RegisterUserResponse:
email=_clean_optional(req.email), email=_clean_optional(req.email),
default_backend_id=backend.backend_id, default_backend_id=backend.backend_id,
) )
await _ensure_user_file_storage_settings(backend.backend_id)
return RegisterUserResponse( return RegisterUserResponse(
user=user, user=user,
backend=RegisterUserBackendResult( backend=RegisterUserBackendResult(
@ -686,6 +765,23 @@ async def get_internal_outlook_settings(backend_id: str) -> dict[str, Any]:
return settings.model_dump(mode="json") return settings.model_dump(mode="json")
@app.get("/internal/backends/{backend_id}/settings/minio", dependencies=[Depends(_require_internal)])
async def get_internal_minio_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
settings = store.get_minio_settings(backend_id)
if settings is None:
raise HTTPException(status_code=404, detail="MinIO settings not configured")
return settings.model_dump(mode="json")
@app.delete("/internal/backends/{backend_id}/user-files", dependencies=[Depends(_require_internal)])
async def delete_internal_user_files(
backend_id: str,
best_effort: bool = Query(default=False),
) -> dict[str, Any]:
return await _deprovision_user_file_storage(backend_id, best_effort=best_effort)
@app.get("/internal/backends/{backend_id}/settings/channels", dependencies=[Depends(_require_internal)]) @app.get("/internal/backends/{backend_id}/settings/channels", dependencies=[Depends(_require_internal)])
async def list_internal_channel_settings(backend_id: str) -> dict[str, Any]: async def list_internal_channel_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id) _ensure_backend(backend_id)

View File

@ -0,0 +1,317 @@
from __future__ import annotations
from dataclasses import dataclass
import json
import os
import re
import secrets
from typing import Any
from .models import MinIOSettings
@dataclass(slots=True)
class MinIOProvisioningConfig:
enabled: bool
endpoint: str
public_endpoint: str
admin_access_key: str
admin_secret_key: str
bucket: str
secure: bool
region: str | None
class MinIOProvisioningError(RuntimeError):
pass
class MinIODeprovisioningError(RuntimeError):
pass
def minio_provisioning_config_from_env() -> MinIOProvisioningConfig:
endpoint = _env("USER_FILES_MINIO_ENDPOINT") or _env("MINIO_ENDPOINT")
public_endpoint = _env("USER_FILES_MINIO_PUBLIC_ENDPOINT") or endpoint
return MinIOProvisioningConfig(
enabled=_truthy(_env("USER_FILES_MINIO_PROVISIONING_ENABLED")),
endpoint=endpoint,
public_endpoint=public_endpoint,
admin_access_key=_env("USER_FILES_MINIO_ADMIN_ACCESS_KEY") or _env("MINIO_ROOT_USER"),
admin_secret_key=_env("USER_FILES_MINIO_ADMIN_SECRET_KEY") or _env("MINIO_ROOT_PASSWORD"),
bucket=_env("USER_FILES_MINIO_BUCKET") or "beaver-user-files",
secure=_truthy(_env("USER_FILES_MINIO_SECURE")),
region=_env("USER_FILES_MINIO_REGION") or None,
)
def provision_user_file_minio_settings(
*,
backend_id: str,
existing: MinIOSettings | None,
config: MinIOProvisioningConfig | None = None,
) -> MinIOSettings | None:
cfg = config or minio_provisioning_config_from_env()
if not cfg.enabled:
return existing
if existing is not None and existing.configured and existing.secret_key:
return existing
if not cfg.endpoint or not cfg.admin_access_key or not cfg.admin_secret_key:
raise MinIOProvisioningError("MinIO provisioning requires endpoint and admin credentials")
namespace = default_namespace(backend_id)
access_key = _access_key_for_backend(backend_id)
secret_key = secrets.token_urlsafe(32)
policy_name = _policy_name_for_backend(backend_id)
try:
from minio import Minio
from minio import MinioAdmin
from minio.credentials import StaticProvider
client = Minio(
endpoint=cfg.endpoint,
access_key=cfg.admin_access_key,
secret_key=cfg.admin_secret_key,
secure=cfg.secure,
region=cfg.region,
)
if not client.bucket_exists(cfg.bucket):
client.make_bucket(cfg.bucket, location=cfg.region)
admin = MinioAdmin(
endpoint=cfg.endpoint,
credentials=StaticProvider(
access_key=cfg.admin_access_key,
secret_key=cfg.admin_secret_key,
),
secure=cfg.secure,
)
try:
admin.user_add(access_key, secret_key)
except Exception:
# Treat an existing user as idempotent; keep the newly generated
# secret only when user creation succeeded.
if existing is not None and existing.secret_key:
secret_key = existing.secret_key
else:
raise
policy = _namespace_policy(bucket=cfg.bucket, namespace=namespace)
admin.policy_add(policy_name, policy=policy)
admin.attach_policy(policies=[policy_name], user=access_key)
except Exception as exc:
raise MinIOProvisioningError(f"MinIO user file provisioning failed: {exc}") from exc
return MinIOSettings(
endpoint=cfg.public_endpoint,
access_key=access_key,
secret_key=secret_key,
bucket=cfg.bucket,
namespace=namespace,
secure=cfg.secure,
region=cfg.region,
)
def deprovision_user_file_minio_resources(
*,
backend_id: str,
existing: MinIOSettings | None,
best_effort: bool = False,
config: MinIOProvisioningConfig | None = None,
) -> dict[str, Any]:
cfg = config or minio_provisioning_config_from_env()
settings_found = existing is not None
result: dict[str, Any] = {
"ok": True,
"backend_id": backend_id,
"settings_found": settings_found,
"best_effort": best_effort,
"bucket": "skipped",
"objects": {"status": "absent", "deleted": 0},
"user": {"status": "absent"},
"policy": {"status": "absent"},
}
if existing is None and not best_effort:
return result
bucket = (existing.bucket if existing else None) or cfg.bucket
namespace = (existing.namespace if existing else None) or default_namespace(backend_id)
access_key = (existing.access_key if existing else None) or _access_key_for_backend(backend_id)
policy_name = _policy_name_for_backend(backend_id)
if not bucket or not namespace.strip("/"):
raise MinIODeprovisioningError("MinIO deprovisioning requires bucket and namespace")
if not cfg.endpoint or not cfg.admin_access_key or not cfg.admin_secret_key:
raise MinIODeprovisioningError("MinIO deprovisioning requires endpoint and admin credentials")
try:
from minio import Minio
from minio import MinioAdmin
from minio.credentials import StaticProvider
from minio.deleteobjects import DeleteObject
client = Minio(
endpoint=cfg.endpoint,
access_key=cfg.admin_access_key,
secret_key=cfg.admin_secret_key,
secure=cfg.secure,
region=cfg.region,
)
admin = MinioAdmin(
endpoint=cfg.endpoint,
credentials=StaticProvider(
access_key=cfg.admin_access_key,
secret_key=cfg.admin_secret_key,
),
secure=cfg.secure,
)
prefix = namespace.strip("/")
if client.bucket_exists(bucket):
result["bucket"] = "present"
objects = [
DeleteObject(item.object_name)
for item in client.list_objects(bucket, prefix=f"{prefix}/", recursive=True)
if getattr(item, "object_name", None)
]
errors = list(client.remove_objects(bucket, objects)) if objects else []
if errors:
result["objects"] = {
"status": "failed",
"deleted": 0,
"errors": [_safe_error_text(error) for error in errors],
}
result["ok"] = False
else:
result["objects"] = {
"status": "removed" if objects else "absent",
"deleted": len(objects),
}
else:
result["bucket"] = "absent"
result["objects"] = {"status": "absent", "deleted": 0}
result["policy_detach"] = _admin_step(
lambda: _call_admin_method(
admin,
["detach_policy"],
policies=[policy_name],
user=access_key,
)
)
result["user"] = _admin_step(lambda: _call_admin_method(admin, ["user_remove", "remove_user"], access_key))
result["policy"] = _admin_step(
lambda: _call_admin_method(admin, ["policy_remove", "remove_policy"], policy_name)
)
except Exception as exc:
raise MinIODeprovisioningError(f"MinIO user file deprovisioning failed: {_safe_error_text(exc)}") from exc
result["ok"] = bool(result["ok"]) and all(
item.get("status") != "failed"
for item in (
result["objects"],
result["policy_detach"],
result["user"],
result["policy"],
)
if isinstance(item, dict)
)
return result
def default_namespace(backend_id: str) -> str:
return f"users/{backend_id.strip().strip('/')}"
def _namespace_policy(*, bucket: str, namespace: str) -> dict[str, object]:
prefix = namespace.strip("/")
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetBucketLocation"],
"Resource": [f"arn:aws:s3:::{bucket}"],
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": [f"arn:aws:s3:::{bucket}"],
"Condition": {"StringLike": {"s3:prefix": [f"{prefix}/*", prefix]}},
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
],
"Resource": [f"arn:aws:s3:::{bucket}/{prefix}/*"],
},
],
}
def policy_json_for_backend(*, backend_id: str, bucket: str = "beaver-user-files") -> str:
return json.dumps(_namespace_policy(bucket=bucket, namespace=default_namespace(backend_id)), indent=2)
def _access_key_for_backend(backend_id: str) -> str:
slug = re.sub(r"[^A-Za-z0-9_-]+", "-", backend_id.strip()).strip("-_")
if not slug:
slug = secrets.token_hex(6)
return f"beaver-{slug}"[:64]
def _policy_name_for_backend(backend_id: str) -> str:
slug = re.sub(r"[^A-Za-z0-9_-]+", "-", backend_id.strip()).strip("-_")
return f"beaver-user-files-{slug or secrets.token_hex(6)}"[:128]
def _call_admin_method(admin: object, names: list[str], *args: Any, **kwargs: Any) -> Any:
for name in names:
method = getattr(admin, name, None)
if callable(method):
return method(*args, **kwargs)
raise AttributeError(f"MinIO admin client does not support any of: {', '.join(names)}")
def _admin_step(action: Any) -> dict[str, Any]:
try:
action()
except Exception as exc:
if _is_absent_error(exc):
return {"status": "absent"}
return {"status": "failed", "error": _safe_error_text(exc)}
return {"status": "removed"}
def _is_absent_error(exc: Exception) -> bool:
text = _safe_error_text(exc).lower()
absent_markers = (
"not found",
"notfound",
"no such",
"does not exist",
"doesn't exist",
"specified user does not exist",
"specified policy does not exist",
"the specified bucket does not exist",
)
return any(marker in text for marker in absent_markers)
def _safe_error_text(exc: object) -> str:
text = str(exc).strip()
return text or exc.__class__.__name__
def _env(name: str) -> str:
return os.getenv(name, "").strip()
def _truthy(value: str) -> bool:
return value.lower() in {"1", "true", "yes", "on"}

View File

@ -77,6 +77,24 @@ class OutlookSettings(BaseModel):
return data return data
class MinIOSettings(BaseModel):
configured: bool = True
endpoint: str
access_key: str
secret_key: str
bucket: str | None = None
namespace: str | None = None
secure: bool = False
region: str | None = None
updated_at: str = Field(default_factory=utcnow_iso)
def masked_dict(self) -> dict[str, Any]:
data = self.model_dump()
data.pop("secret_key", None)
data["secret_key_masked"] = True
return data
class BackendRoutingPayload(BaseModel): class BackendRoutingPayload(BaseModel):
name: str | None = None name: str | None = None
backend_id: str | None = None backend_id: str | None = None

View File

@ -9,6 +9,7 @@ dependencies = [
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
"pydantic>=2.12.0,<3.0.0", "pydantic>=2.12.0,<3.0.0",
"cryptography>=45.0.0,<46.0.0", "cryptography>=45.0.0,<46.0.0",
"minio>=7.2.0,<8.0.0",
"PyJWT>=2.10.0,<3.0.0", "PyJWT>=2.10.0,<3.0.0",
"python-multipart>=0.0.20,<1.0.0", "python-multipart>=0.0.20,<1.0.0",
] ]

View File

@ -0,0 +1,212 @@
from __future__ import annotations
import importlib
import sys
from types import ModuleType
from typing import Any
from fastapi.testclient import TestClient
from app.minio_provisioning import (
MinIOProvisioningConfig,
deprovision_user_file_minio_resources,
)
from app.models import MinIOSettings
class _FakeObject:
def __init__(self, object_name: str) -> None:
self.object_name = object_name
class _FakeMinio:
bucket_exists_value = True
objects: list[str] = []
removed_objects: list[str] = []
def __init__(self, **_kwargs: Any) -> None:
pass
def bucket_exists(self, bucket: str) -> bool:
return self.bucket_exists_value
def list_objects(self, bucket: str, *, prefix: str, recursive: bool) -> list[_FakeObject]:
return [_FakeObject(name) for name in self.objects if name.startswith(prefix)]
def remove_objects(self, bucket: str, objects: list[Any]) -> list[Any]:
self.removed_objects.extend(item.object_name for item in objects)
return []
class _FakeAdmin:
calls: list[tuple[str, Any]] = []
missing = False
def __init__(self, **_kwargs: Any) -> None:
pass
def detach_policy(self, **kwargs: Any) -> None:
self.calls.append(("detach_policy", kwargs))
if self.missing:
raise RuntimeError("policy not found")
def user_remove(self, access_key: str) -> None:
self.calls.append(("user_remove", access_key))
if self.missing:
raise RuntimeError("user not found")
def policy_remove(self, policy_name: str) -> None:
self.calls.append(("policy_remove", policy_name))
if self.missing:
raise RuntimeError("policy not found")
class _FakeStaticProvider:
def __init__(self, **_kwargs: Any) -> None:
pass
class _FakeDeleteObject:
def __init__(self, object_name: str) -> None:
self.object_name = object_name
def _install_fake_minio(monkeypatch) -> None:
minio_module = ModuleType("minio")
minio_module.Minio = _FakeMinio
minio_module.MinioAdmin = _FakeAdmin
credentials_module = ModuleType("minio.credentials")
credentials_module.StaticProvider = _FakeStaticProvider
deleteobjects_module = ModuleType("minio.deleteobjects")
deleteobjects_module.DeleteObject = _FakeDeleteObject
monkeypatch.setitem(sys.modules, "minio", minio_module)
monkeypatch.setitem(sys.modules, "minio.credentials", credentials_module)
monkeypatch.setitem(sys.modules, "minio.deleteobjects", deleteobjects_module)
_FakeMinio.bucket_exists_value = True
_FakeMinio.objects = []
_FakeMinio.removed_objects = []
_FakeAdmin.calls = []
_FakeAdmin.missing = False
def _config() -> MinIOProvisioningConfig:
return MinIOProvisioningConfig(
enabled=True,
endpoint="minio.local:9000",
public_endpoint="minio.local:9000",
admin_access_key="admin",
admin_secret_key="admin-secret",
bucket="beaver-user-files",
secure=False,
region=None,
)
def _settings() -> MinIOSettings:
return MinIOSettings(
endpoint="minio.local:9000",
access_key="beaver-alice",
secret_key="alice-secret",
bucket="beaver-user-files",
namespace="users/alice",
)
def _client(tmp_path, monkeypatch) -> TestClient:
monkeypatch.setenv("AUTHZ_DATA_DIR", str(tmp_path))
monkeypatch.setenv("AUTHZ_PRIVATE_KEY_PATH", str(tmp_path / "signing_key.pem"))
monkeypatch.setenv("AUTHZ_INTERNAL_TOKEN", "test-internal-token")
monkeypatch.setenv("USER_FILES_MINIO_ENDPOINT", "minio.local:9000")
monkeypatch.setenv("USER_FILES_MINIO_ADMIN_ACCESS_KEY", "admin")
monkeypatch.setenv("USER_FILES_MINIO_ADMIN_SECRET_KEY", "admin-secret")
monkeypatch.setenv("USER_FILES_MINIO_BUCKET", "beaver-user-files")
import app.main as main
main = importlib.reload(main)
return TestClient(main.app)
def _register_backend(client: TestClient) -> None:
response = client.post(
"/backends/register",
json={"backend_id": "alice", "name": "Alice", "base_url": "http://alice.local"},
)
assert response.status_code == 200
def test_deprovision_removes_namespace_resources_without_secrets(monkeypatch) -> None:
_install_fake_minio(monkeypatch)
_FakeMinio.objects = [
"users/alice/uploads/a.txt",
"users/alice/outputs/b.txt",
"users/bob/uploads/c.txt",
]
result = deprovision_user_file_minio_resources(
backend_id="alice",
existing=_settings(),
config=_config(),
)
assert result["ok"] is True
assert result["objects"] == {"status": "removed", "deleted": 2}
assert _FakeMinio.removed_objects == ["users/alice/uploads/a.txt", "users/alice/outputs/b.txt"]
assert ("user_remove", "beaver-alice") in _FakeAdmin.calls
assert ("policy_remove", "beaver-user-files-alice") in _FakeAdmin.calls
assert "secret" not in str(result).lower()
def test_deprovision_is_idempotent_when_resources_are_absent(monkeypatch) -> None:
_install_fake_minio(monkeypatch)
_FakeMinio.bucket_exists_value = False
_FakeAdmin.missing = True
result = deprovision_user_file_minio_resources(
backend_id="alice",
existing=_settings(),
config=_config(),
)
assert result["ok"] is True
assert result["bucket"] == "absent"
assert result["objects"] == {"status": "absent", "deleted": 0}
assert result["user"] == {"status": "absent"}
assert result["policy"] == {"status": "absent"}
def test_internal_user_file_deprovision_requires_internal_token(tmp_path, monkeypatch) -> None:
with _client(tmp_path, monkeypatch) as client:
unauthorized = client.delete("/internal/backends/alice/user-files")
assert unauthorized.status_code == 401
def test_internal_user_file_deprovision_deletes_settings_without_returning_secret(tmp_path, monkeypatch) -> None:
_install_fake_minio(monkeypatch)
with _client(tmp_path, monkeypatch) as client:
_register_backend(client)
client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio.local:9000",
"access_key": "beaver-alice",
"secret_key": "alice-secret",
"bucket": "beaver-user-files",
"namespace": "users/alice",
},
)
deleted = client.delete(
"/internal/backends/alice/user-files",
headers={"Authorization": "Bearer test-internal-token"},
)
after_delete = client.get("/backends/alice/settings/minio")
assert deleted.status_code == 200
payload = deleted.json()
assert payload["ok"] is True
assert payload["settings"] == {"status": "removed"}
assert "secret" not in str(payload).lower()
assert after_delete.json() == {"configured": False}

View File

@ -0,0 +1,139 @@
from __future__ import annotations
import importlib
from fastapi.testclient import TestClient
from app.minio_provisioning import default_namespace, policy_json_for_backend
def _client(tmp_path, monkeypatch) -> TestClient:
monkeypatch.setenv("AUTHZ_DATA_DIR", str(tmp_path))
monkeypatch.setenv("AUTHZ_PRIVATE_KEY_PATH", str(tmp_path / "signing_key.pem"))
monkeypatch.setenv("AUTHZ_INTERNAL_TOKEN", "test-internal-token")
import app.main as main
main = importlib.reload(main)
return TestClient(main.app)
def _register_backend(client: TestClient) -> None:
response = client.post(
"/backends/register",
json={"backend_id": "alice", "name": "Alice", "base_url": "http://alice.local"},
)
assert response.status_code == 200
def test_minio_settings_round_trip_with_masked_public_read(tmp_path, monkeypatch) -> None:
with _client(tmp_path, monkeypatch) as client:
_register_backend(client)
saved = client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio.local:9000",
"access_key": "alice-access",
"secret_key": "alice-secret",
"bucket": "beaver-user-files",
"namespace": "users/alice",
"secure": True,
"region": "us-east-1",
},
)
public = client.get("/backends/alice/settings/minio")
internal = client.get(
"/internal/backends/alice/settings/minio",
headers={"Authorization": "Bearer test-internal-token"},
)
assert saved.status_code == 200
assert saved.json()["configured"] is True
assert saved.json()["endpoint"] == "minio.local:9000"
assert saved.json()["secret_key_masked"] is True
assert "secret_key" not in saved.json()
assert public.status_code == 200
assert public.json()["access_key"] == "alice-access"
assert public.json()["bucket"] == "beaver-user-files"
assert public.json()["namespace"] == "users/alice"
assert public.json()["secret_key_masked"] is True
assert "secret_key" not in public.json()
assert internal.status_code == 200
assert internal.json()["secret_key"] == "alice-secret"
assert internal.json()["bucket"] == "beaver-user-files"
assert internal.json()["namespace"] == "users/alice"
def test_minio_settings_preserve_secret_on_masked_update(tmp_path, monkeypatch) -> None:
with _client(tmp_path, monkeypatch) as client:
_register_backend(client)
first = client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio.local:9000",
"access_key": "alice-access",
"secret_key": "alice-secret",
"bucket": "beaver-user-files",
"namespace": "users/alice",
},
)
updated = client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio2.local:9000",
"access_key": "alice-access-2",
"secret_key": "",
"bucket": "beaver-user-files",
"namespace": "users/alice-v2",
},
)
internal = client.get(
"/internal/backends/alice/settings/minio",
headers={"Authorization": "Bearer test-internal-token"},
)
assert first.status_code == 200
assert updated.status_code == 200
assert updated.json()["endpoint"] == "minio2.local:9000"
assert updated.json()["namespace"] == "users/alice-v2"
assert internal.status_code == 200
assert internal.json()["secret_key"] == "alice-secret"
assert internal.json()["bucket"] == "beaver-user-files"
assert internal.json()["namespace"] == "users/alice-v2"
def test_minio_settings_delete_and_missing_behavior(tmp_path, monkeypatch) -> None:
with _client(tmp_path, monkeypatch) as client:
_register_backend(client)
missing_public = client.get("/backends/alice/settings/minio")
missing_internal = client.get(
"/internal/backends/alice/settings/minio",
headers={"Authorization": "Bearer test-internal-token"},
)
client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio.local:9000",
"access_key": "alice-access",
"secret_key": "alice-secret",
"bucket": "beaver-user-files",
"namespace": "users/alice",
},
)
deleted = client.delete("/backends/alice/settings/minio")
after_delete = client.get("/backends/alice/settings/minio")
assert missing_public.status_code == 200
assert missing_public.json() == {"configured": False}
assert missing_internal.status_code == 404
assert deleted.status_code == 200
assert deleted.json() == {"ok": True}
assert after_delete.status_code == 200
assert after_delete.json() == {"configured": False}
def test_minio_namespace_policy_is_scoped_to_backend_prefix() -> None:
policy = policy_json_for_backend(backend_id="alice", bucket="beaver-user-files")
assert default_namespace("alice") == "users/alice"
assert "arn:aws:s3:::beaver-user-files/users/alice/*" in policy
assert "users/bob" not in policy

View File

@ -1,6 +1,10 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.10" requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]] [[package]]
name = "annotated-doc" name = "annotated-doc"
@ -34,6 +38,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
] ]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" },
{ url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" },
{ url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" },
{ url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" },
{ url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" },
]
[[package]] [[package]]
name = "authz-service" name = "authz-service"
version = "0.1.0" version = "0.1.0"
@ -42,6 +94,7 @@ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "minio" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "python-multipart" }, { name = "python-multipart" },
@ -58,6 +111,7 @@ requires-dist = [
{ name = "cryptography", specifier = ">=45.0.0,<46.0.0" }, { name = "cryptography", specifier = ">=45.0.0,<46.0.0" },
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0.0" },
{ name = "minio", specifier = ">=7.2.0,<8.0.0" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, { name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pyjwt", specifier = ">=2.10.0,<3.0.0" }, { name = "pyjwt", specifier = ">=2.10.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" },
@ -351,6 +405,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "minio"
version = "7.2.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi" },
{ name = "certifi" },
{ name = "pycryptodome" },
{ name = "typing-extensions" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.0"
@ -378,6 +448,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
] ]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@ -717,6 +822,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
] ]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.41.0" version = "0.41.0"

View File

@ -18,7 +18,7 @@ DEFAULT_AUTHZ_OUTLOOK_MCP_URL=
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
DEPLOY_PUBLIC_SCHEME=http DEPLOY_PUBLIC_SCHEME=http
DEPLOY_PUBLIC_BASE_DOMAIN=203.0.113.10.nip.io DEPLOY_PUBLIC_BASE_DOMAIN=localhost
DEPLOY_PUBLIC_HOST_TEMPLATE={slug}.{base_domain} DEPLOY_PUBLIC_HOST_TEMPLATE={slug}.{base_domain}
DEPLOY_PUBLIC_PORT=8088 DEPLOY_PUBLIC_PORT=8088
DEPLOY_AUTO_START_PROXY=1 DEPLOY_AUTO_START_PROXY=1
@ -26,5 +26,5 @@ DEPLOY_HEALTH_TIMEOUT_SECONDS=60
DEPLOY_HEALTH_INTERVAL_SECONDS=1 DEPLOY_HEALTH_INTERVAL_SECONDS=1
# Passed through to create-instance.sh when the app-instance image is rebuilt. # Passed through to create-instance.sh when the app-instance image is rebuilt.
AUTH_PORTAL_URL=http://203.0.113.10:3081 AUTH_PORTAL_URL=http://127.0.0.1:3081
AUTH_PORTAL_PORT=3081 AUTH_PORTAL_PORT=3081

View File

@ -32,7 +32,7 @@
默认实例 URL 形如: 默认实例 URL 形如:
```text ```text
http://<instance-slug>.127.0.0.1.nip.io:8088 http://<instance-slug>.localhost:8088
``` ```
实例容器本身的 `20000-29999` 端口默认只绑定到部署机 `127.0.0.1`,外部入口应走 `router-proxy` 实例容器本身的 `20000-29999` 端口默认只绑定到部署机 `127.0.0.1`,外部入口应走 `router-proxy`

View File

@ -11,6 +11,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib import error as urllib_error from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request from urllib import request as urllib_request
@ -37,15 +38,18 @@ API_TOKEN = os.environ.get("DEPLOY_CONTROL_API_TOKEN", "").strip()
INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "beaver/app-instance:latest").strip() INSTANCE_IMAGE = os.environ.get("APP_INSTANCE_IMAGE", "beaver/app-instance:latest").strip()
INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "beaver-instance-edge").strip() INSTANCE_NETWORK_NAME = os.environ.get("APP_INSTANCE_NETWORK_NAME", "beaver-instance-edge").strip()
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip() DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
DEFAULT_AUTHZ_INTERNAL_TOKEN = os.environ.get("DEFAULT_AUTHZ_INTERNAL_TOKEN", "").strip()
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip() DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp" DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
DEFAULT_USER_FILES_MAX_UPLOAD_BYTES = os.environ.get("DEFAULT_USER_FILES_MAX_UPLOAD_BYTES", "").strip()
PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http" PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http"
PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "127.0.0.1.nip.io").strip() PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "localhost").strip()
PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip() PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip()
PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088") PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088")
AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"} AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"}
HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60") HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60")
HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1") HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1")
UPSTREAM_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_UPSTREAM_TIMEOUT_SECONDS", "90").strip() or "90")
INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080") INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080")
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0" SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090") SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
@ -263,9 +267,13 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
] ]
if authz_base_url: if authz_base_url:
command.extend(["--authz-base-url", authz_base_url]) command.extend(["--authz-base-url", authz_base_url])
if DEFAULT_AUTHZ_INTERNAL_TOKEN:
command.extend(["--authz-internal-token", DEFAULT_AUTHZ_INTERNAL_TOKEN])
if authz_outlook_mcp_url: if authz_outlook_mcp_url:
command.extend(["--authz-outlook-mcp-url", authz_outlook_mcp_url]) command.extend(["--authz-outlook-mcp-url", authz_outlook_mcp_url])
command.extend(["--outlook-mcp-server-id", DEFAULT_OUTLOOK_MCP_SERVER_ID]) command.extend(["--outlook-mcp-server-id", DEFAULT_OUTLOOK_MCP_SERVER_ID])
if DEFAULT_USER_FILES_MAX_UPLOAD_BYTES:
command.extend(["--user-files-max-upload-bytes", DEFAULT_USER_FILES_MAX_UPLOAD_BYTES])
if payload.get("replace") is True: if payload.get("replace") is True:
command.append("--replace") command.append("--replace")
@ -498,21 +506,108 @@ def resolve_instance(payload: dict[str, Any]) -> dict[str, Any]:
} }
def remove_instance(instance_id: str, purge_data: bool) -> dict[str, Any]: def deprovision_user_files(
*,
backend_id: str,
authz_base_url: str,
best_effort: bool = True,
) -> dict[str, Any]:
if not backend_id:
return {"ok": False, "status": "failed", "error": "backend_id is missing"}
if not authz_base_url:
return {"ok": False, "status": "failed", "error": "AuthZ base URL is not configured"}
if not DEFAULT_AUTHZ_INTERNAL_TOKEN:
return {"ok": False, "status": "failed", "error": "AuthZ internal token is not configured"}
query = urllib_parse.urlencode({"best_effort": "1" if best_effort else "0"})
quoted_backend_id = urllib_parse.quote(backend_id, safe="")
url = f"{authz_base_url.rstrip('/')}/internal/backends/{quoted_backend_id}/user-files?{query}"
request = urllib_request.Request(
url,
method="DELETE",
headers={
"Authorization": f"Bearer {DEFAULT_AUTHZ_INTERNAL_TOKEN}",
"Accept": "application/json",
},
)
try:
with urllib_request.urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
raw = response.read().decode("utf-8")
except urllib_error.HTTPError as exc:
detail = exc.reason
try:
payload = json.loads(exc.read().decode("utf-8"))
if isinstance(payload, dict):
detail = str(payload.get("detail") or detail)
except Exception:
pass
return {"ok": False, "status": "failed", "error": detail, "status_code": exc.code}
except (urllib_error.URLError, TimeoutError) as exc:
return {"ok": False, "status": "failed", "error": str(exc)}
if not raw.strip():
return {"ok": True, "status": "removed"}
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return {"ok": False, "status": "failed", "error": "AuthZ response was not valid JSON"}
if not isinstance(payload, dict):
return {"ok": False, "status": "failed", "error": "AuthZ response must be a JSON object"}
payload.setdefault("ok", True)
return payload
def remove_instance(instance_id: str, purge_data: bool, purge_user_files: bool = False) -> dict[str, Any]:
if not instance_id.strip(): if not instance_id.strip():
raise ApiError(HTTPStatus.BAD_REQUEST, "instance id is required") raise ApiError(HTTPStatus.BAD_REQUEST, "instance id is required")
record = get_registry_record(instance_id=instance_id)
if record is None:
local_result: dict[str, Any] = {
"instance_id": instance_id,
"status": "already_absent",
"already_absent": True,
}
user_files_result: dict[str, Any] = {"ok": True, "status": "skipped"}
if purge_user_files:
user_files_result = deprovision_user_files(
backend_id=instance_id,
authz_base_url=DEFAULT_AUTHZ_BASE_URL,
best_effort=True,
)
return {
"ok": not purge_user_files or bool(user_files_result.get("ok")),
"instance": local_result,
"local": local_result,
"user_files": user_files_result,
}
backend_id = str(record.get("backend_id", "") or record.get("username", "") or instance_id).strip()
authz_base_url = str(record.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
command = [str(REMOVE_INSTANCE_SCRIPT), "--instance-id", instance_id] command = [str(REMOVE_INSTANCE_SCRIPT), "--instance-id", instance_id]
if purge_data: if purge_data:
command.append("--purge-data") command.append("--purge-data")
output = run_command(command, cwd=APP_INSTANCE_DIR) output = run_command(command, cwd=APP_INSTANCE_DIR)
ensure_proxy() ensure_proxy()
result: dict[str, str] = {} local_result: dict[str, str] = {}
for line in output.splitlines(): for line in output.splitlines():
if "=" not in line: if "=" not in line:
continue continue
key, value = line.split("=", 1) key, value = line.split("=", 1)
result[key] = value local_result[key] = value
return result
user_files_result: dict[str, Any] = {"ok": True, "status": "skipped"}
if purge_user_files:
user_files_result = deprovision_user_files(
backend_id=backend_id,
authz_base_url=authz_base_url,
best_effort=True,
)
return {
"ok": bool(local_result) and (not purge_user_files or bool(user_files_result.get("ok"))),
"instance": local_result,
"local": local_result,
"user_files": user_files_result,
}
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@ -587,11 +682,17 @@ class Handler(BaseHTTPRequestHandler):
def do_DELETE(self) -> None: # noqa: N802 def do_DELETE(self) -> None: # noqa: N802
try: try:
self._require_auth() self._require_auth()
if not self.path.startswith("/api/instances/"): parsed = urllib_parse.urlparse(self.path)
if not parsed.path.startswith("/api/instances/"):
raise ApiError(HTTPStatus.NOT_FOUND, "not found") raise ApiError(HTTPStatus.NOT_FOUND, "not found")
instance_id = self.path.rsplit("/", 1)[-1] instance_id = urllib_parse.unquote(parsed.path.rsplit("/", 1)[-1])
query = urllib_parse.parse_qs(parsed.query)
purge_data = self.headers.get("X-Purge-Data", "").strip() == "1" purge_data = self.headers.get("X-Purge-Data", "").strip() == "1"
self._json_response(HTTPStatus.OK, remove_instance(instance_id, purge_data)) purge_user_files = (
self.headers.get("X-Purge-User-Files", "").strip() == "1"
or query.get("purge_user_files", [""])[-1] in {"1", "true", "True", "yes"}
)
self._json_response(HTTPStatus.OK, remove_instance(instance_id, purge_data, purge_user_files))
except ApiError as exc: except ApiError as exc:
self._json_response(exc.status_code, {"detail": exc.detail}) self._json_response(exc.status_code, {"detail": exc.detail})

View File

@ -0,0 +1,148 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from typing import Any
SERVER_PATH = Path(__file__).resolve().parents[1] / "server.py"
def _load_server_module():
spec = importlib.util.spec_from_file_location("deploy_control_server_for_tests", SERVER_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _record() -> dict[str, Any]:
return {
"instance_id": "smoke-user",
"username": "smoke-user",
"backend_id": "smoke-backend",
"authz_base_url": "http://authz.local",
}
def test_remove_instance_without_user_file_purge_skips_authz(monkeypatch) -> None:
server = _load_server_module()
calls: list[Any] = []
monkeypatch.setattr(server, "get_registry_record", lambda **_kwargs: _record())
monkeypatch.setattr(
server,
"run_command",
lambda *_args, **_kwargs: "instance_id=smoke-user\npurged_data=1",
)
monkeypatch.setattr(server, "ensure_proxy", lambda: None)
monkeypatch.setattr(server, "deprovision_user_files", lambda **kwargs: calls.append(kwargs))
result = server.remove_instance("smoke-user", purge_data=True, purge_user_files=False)
assert result["ok"] is True
assert result["local"]["instance_id"] == "smoke-user"
assert result["user_files"] == {"ok": True, "status": "skipped"}
assert calls == []
def test_remove_instance_with_user_file_purge_calls_authz_after_resolving_backend(monkeypatch) -> None:
server = _load_server_module()
calls: list[dict[str, Any]] = []
monkeypatch.setattr(server, "get_registry_record", lambda **_kwargs: _record())
monkeypatch.setattr(
server,
"run_command",
lambda *_args, **_kwargs: "instance_id=smoke-user\npurged_data=1",
)
monkeypatch.setattr(server, "ensure_proxy", lambda: None)
def fake_deprovision(**kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"ok": True, "objects": {"status": "removed", "deleted": 1}}
monkeypatch.setattr(server, "deprovision_user_files", fake_deprovision)
result = server.remove_instance("smoke-user", purge_data=True, purge_user_files=True)
assert result["ok"] is True
assert calls == [
{
"backend_id": "smoke-backend",
"authz_base_url": "http://authz.local",
"best_effort": True,
}
]
assert result["user_files"]["objects"] == {"status": "removed", "deleted": 1}
def test_remove_instance_reports_user_file_cleanup_failure_separately(monkeypatch) -> None:
server = _load_server_module()
monkeypatch.setattr(server, "get_registry_record", lambda **_kwargs: _record())
monkeypatch.setattr(
server,
"run_command",
lambda *_args, **_kwargs: "instance_id=smoke-user\npurged_data=1",
)
monkeypatch.setattr(server, "ensure_proxy", lambda: None)
monkeypatch.setattr(
server,
"deprovision_user_files",
lambda **_kwargs: {"ok": False, "status": "failed", "error": "AuthZ unavailable"},
)
result = server.remove_instance("smoke-user", purge_data=True, purge_user_files=True)
assert result["ok"] is False
assert result["local"]["instance_id"] == "smoke-user"
assert result["user_files"] == {"ok": False, "status": "failed", "error": "AuthZ unavailable"}
def test_remove_already_absent_instance_is_idempotent_without_file_purge(monkeypatch) -> None:
server = _load_server_module()
calls: list[Any] = []
monkeypatch.setattr(server, "get_registry_record", lambda **_kwargs: None)
monkeypatch.setattr(server, "run_command", lambda *_args, **_kwargs: calls.append(_args))
monkeypatch.setattr(server, "deprovision_user_files", lambda **kwargs: calls.append(kwargs))
result = server.remove_instance("smoke-user", purge_data=True, purge_user_files=False)
assert result["ok"] is True
assert result["local"] == {
"instance_id": "smoke-user",
"status": "already_absent",
"already_absent": True,
}
assert result["user_files"] == {"ok": True, "status": "skipped"}
assert calls == []
def test_remove_already_absent_instance_can_retry_user_file_cleanup(monkeypatch) -> None:
server = _load_server_module()
calls: list[dict[str, Any]] = []
monkeypatch.setattr(server, "DEFAULT_AUTHZ_BASE_URL", "http://authz.local")
monkeypatch.setattr(server, "get_registry_record", lambda **_kwargs: None)
monkeypatch.setattr(server, "run_command", lambda *_args, **_kwargs: "should-not-run")
def fake_deprovision(**kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"ok": True, "settings_found": False, "objects": {"status": "absent"}}
monkeypatch.setattr(server, "deprovision_user_files", fake_deprovision)
result = server.remove_instance("smoke-user", purge_data=True, purge_user_files=True)
assert result["ok"] is True
assert result["local"]["already_absent"] is True
assert calls == [
{
"backend_id": "smoke-user",
"authz_base_url": "http://authz.local",
"best_effort": True,
}
]
assert result["user_files"]["settings_found"] is False

View File

@ -49,7 +49,7 @@ cd /home/ivan/xuan/beaver_project/router-proxy
如果 deploy-control 侧使用默认配置,实例 URL 形如: 如果 deploy-control 侧使用默认配置,实例 URL 形如:
```text ```text
http://<instance-slug>.127.0.0.1.nip.io:8088 http://<instance-slug>.localhost:8088
``` ```
只要本机或 DNS 能把该域名解析到代理所在机器,就会由该代理转发到目标实例容器。 只要本机或 DNS 能把该域名解析到代理所在机器,就会由该代理转发到目标实例容器。

View File

@ -12,7 +12,7 @@ http {
tcp_nopush on; tcp_nopush on;
tcp_nodelay on; tcp_nodelay on;
keepalive_timeout 65; keepalive_timeout 65;
client_max_body_size 100m; client_max_body_size 5g;
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;

View File

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Check that a provisioned MinIO user can access only its own prefix."""
from __future__ import annotations
import os
import sys
from io import BytesIO
from minio import Minio
from minio.error import S3Error
def main() -> int:
endpoint = _require("BEAVER_CHECK_MINIO_ENDPOINT")
bucket = os.getenv("BEAVER_CHECK_MINIO_BUCKET", "beaver-user-files").strip()
access_key = _require("BEAVER_CHECK_MINIO_ACCESS_KEY")
secret_key = _require("BEAVER_CHECK_MINIO_SECRET_KEY")
own_backend = _require("BEAVER_CHECK_MINIO_BACKEND_ID")
other_backend = os.getenv("BEAVER_CHECK_MINIO_OTHER_BACKEND_ID", "policy-denied-other").strip()
secure = os.getenv("BEAVER_CHECK_MINIO_SECURE", "0").strip().lower() in {"1", "true", "yes", "on"}
client = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
own_object = f"users/{own_backend}/uploads/policy-check.txt"
other_object = f"users/{other_backend}/uploads/policy-check.txt"
client.put_object(bucket, own_object, BytesIO(b"ok"), length=2, content_type="text/plain")
client.stat_object(bucket, own_object)
try:
client.put_object(bucket, other_object, BytesIO(b"no"), length=2, content_type="text/plain")
except S3Error as exc:
if exc.code in {"AccessDenied", "AccessDeniedException"}:
print(f"ok: {access_key} can access {own_object} and is denied for {other_object}")
return 0
raise
print(f"error: {access_key} unexpectedly wrote {other_object}", file=sys.stderr)
return 1
def _require(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise SystemExit(f"{name} is required")
return value
if __name__ == "__main__":
raise SystemExit(main())

143
scripts/cleanup-test-users.py Executable file
View File

@ -0,0 +1,143 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request
ROOT_DIR = Path(__file__).resolve().parents[1]
DEFAULT_REGISTRY = ROOT_DIR / "app-instance" / "runtime" / "registry" / "instances.json"
SAFE_PREFIXES = ("smoke", "test", "debug")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Dry-run or purge Beaver smoke/test users.")
parser.add_argument("--registry", default=str(DEFAULT_REGISTRY), help="Path to app-instance registry JSON.")
parser.add_argument("--deploy-control-url", default=os.getenv("DEPLOY_CONTROL_URL", "http://127.0.0.1:8090"))
parser.add_argument("--token", default=os.getenv("DEPLOY_CONTROL_API_TOKEN", ""))
parser.add_argument("--backend-id", action="append", default=[], help="Explicit backend id to clean.")
parser.add_argument("--instance-id", action="append", default=[], help="Explicit instance id to clean.")
parser.add_argument("--username-prefix", action="append", default=[], help="Safe test username prefix, e.g. smoke.")
parser.add_argument("--execute", action="store_true", help="Actually delete matched instances.")
parser.add_argument("--purge-data", action="store_true", help="Delete local instance data when executing.")
parser.add_argument(
"--keep-user-files",
action="store_true",
help="Do not request MinIO/user-file purge when executing.",
)
parser.add_argument(
"--allow-any-prefix",
action="store_true",
help="Allow non-test username prefixes. Use only for controlled maintenance.",
)
return parser.parse_args()
def load_registry(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
payload = json.loads(path.read_text(encoding="utf-8"))
instances = payload.get("instances", []) if isinstance(payload, dict) else []
return [item for item in instances if isinstance(item, dict)]
def selected_instances(args: argparse.Namespace) -> list[dict[str, Any]]:
instances = load_registry(Path(args.registry).expanduser())
explicit_backend_ids = {item.strip() for item in args.backend_id if item.strip()}
explicit_instance_ids = {item.strip() for item in args.instance_id if item.strip()}
prefixes = tuple(item.strip() for item in args.username_prefix if item.strip())
if not explicit_backend_ids and not explicit_instance_ids and not prefixes:
raise SystemExit("Refusing cleanup: provide --backend-id, --instance-id, or --username-prefix.")
unsafe = [prefix for prefix in prefixes if not prefix.startswith(SAFE_PREFIXES)]
if unsafe and not args.allow_any_prefix:
raise SystemExit(
"Refusing cleanup: username prefixes must start with "
f"{', '.join(SAFE_PREFIXES)} unless --allow-any-prefix is set."
)
matches: list[dict[str, Any]] = []
for item in instances:
instance_id = str(item.get("instance_id", "") or "")
backend_id = str(item.get("backend_id", "") or "")
username = str(item.get("username", "") or "")
if instance_id in explicit_instance_ids or backend_id in explicit_backend_ids:
matches.append(item)
continue
if prefixes and any(username.startswith(prefix) or backend_id.startswith(prefix) for prefix in prefixes):
matches.append(item)
return matches
def delete_instance(args: argparse.Namespace, instance_id: str) -> dict[str, Any]:
base_url = args.deploy_control_url.rstrip("/")
url = f"{base_url}/api/instances/{urllib_parse.quote(instance_id, safe='')}"
headers = {"Accept": "application/json"}
if args.token.strip():
headers["Authorization"] = f"Bearer {args.token.strip()}"
if args.purge_data:
headers["X-Purge-Data"] = "1"
if not args.keep_user_files:
headers["X-Purge-User-Files"] = "1"
request = urllib_request.Request(url, method="DELETE", headers=headers)
try:
with urllib_request.urlopen(request, timeout=120) as response:
raw = response.read().decode("utf-8")
except urllib_error.HTTPError as exc:
detail = exc.reason
try:
payload = json.loads(exc.read().decode("utf-8"))
if isinstance(payload, dict):
detail = str(payload.get("detail") or detail)
except Exception:
pass
return {"ok": False, "instance_id": instance_id, "error": detail, "status_code": exc.code}
except urllib_error.URLError as exc:
return {"ok": False, "instance_id": instance_id, "error": str(exc)}
if not raw.strip():
return {"ok": True, "instance_id": instance_id}
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return {"ok": False, "instance_id": instance_id, "error": "deploy-control response was not valid JSON"}
return payload if isinstance(payload, dict) else {"ok": False, "instance_id": instance_id, "error": "unexpected response"}
def main() -> int:
args = parse_args()
matches = selected_instances(args)
planned = [
{
"instance_id": str(item.get("instance_id", "") or ""),
"backend_id": str(item.get("backend_id", "") or ""),
"username": str(item.get("username", "") or ""),
"instance_root": str(item.get("instance_root", "") or ""),
}
for item in matches
]
if not args.execute:
print(json.dumps({"dry_run": True, "count": len(planned), "planned": planned}, indent=2, ensure_ascii=False))
return 0
results = []
for item in planned:
instance_id = item["instance_id"]
if not instance_id:
results.append({"ok": False, "error": "matched registry record is missing instance_id", "record": item})
continue
results.append(delete_instance(args, instance_id))
ok = all(bool(item.get("ok")) for item in results)
print(json.dumps({"dry_run": False, "count": len(results), "ok": ok, "results": results}, indent=2, ensure_ascii=False))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())

190
scripts/smoke-auth-files.mjs Executable file
View File

@ -0,0 +1,190 @@
#!/usr/bin/env node
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
const require = createRequire(import.meta.url);
const { chromium } = require('playwright');
const now = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
const username = process.env.BEAVER_SMOKE_USERNAME || `smoke${now}`;
const password = process.env.BEAVER_SMOKE_PASSWORD || 'TestPass123!';
const email = process.env.BEAVER_SMOKE_EMAIL || `${username}@example.test`;
const portalUrl = (process.env.BEAVER_SMOKE_PORTAL_URL || 'http://127.0.0.1:3081').replace(/\/$/, '');
const nextPath = process.env.BEAVER_SMOKE_NEXT_PATH || '/files';
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined;
const hostResolverRules = process.env.BEAVER_SMOKE_HOST_RESOLVER_RULES || '';
const headless = process.env.BEAVER_SMOKE_HEADLESS !== '0';
const verifyMinio = process.env.BEAVER_SMOKE_VERIFY_MINIO === '1';
const launchArgs = [];
if (hostResolverRules) {
launchArgs.push(`--host-resolver-rules=${hostResolverRules}`);
}
const failedResponses = [];
const failedRequests = [];
function remember(list, value) {
list.push(value);
if (list.length > 30) {
list.shift();
}
}
async function clickFirstVisible(locators) {
for (const locator of locators) {
if (await locator.first().isVisible().catch(() => false)) {
await locator.first().click();
return true;
}
}
return false;
}
async function main() {
const browser = await chromium.launch({
headless,
executablePath,
args: launchArgs,
});
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
page.on('response', (response) => {
if (response.status() >= 400) {
remember(failedResponses, `${response.status()} ${response.url()}`);
}
});
page.on('requestfailed', (request) => {
remember(failedRequests, `${request.url()} ${request.failure()?.errorText || ''}`);
});
try {
const registerUrl = new URL('/register', portalUrl);
registerUrl.searchParams.set('next', nextPath);
await page.goto(registerUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.locator('#username').fill(username);
await page.locator('#email').fill(email);
await page.locator('#password').fill(password);
await page.locator('#confirmPassword').fill(password);
const submitButton = page.locator('form').first().locator('button[type="submit"]');
await submitButton.waitFor({ state: 'visible', timeout: 30000 });
await submitButton.click();
await page.waitForResponse(
(response) => response.url().includes('/api/runtime/register'),
{ timeout: 180000 },
);
await page.getByText(/配置模型|Model Setup/).waitFor({ timeout: 180000 });
const skipped = await clickFirstVisible([
page.getByRole('button', { name: /跳过|Skip/i }),
page.locator('button.secondary-button'),
]);
if (!skipped) {
throw new Error('Model setup skip button was not found');
}
await page.waitForURL(new RegExp(`${nextPath.replace('/', '\\/')}$`), { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.getByText(/文件管理|Files/).waitFor({ timeout: 30000 });
for (const root of ['uploads', 'outputs', 'shared', 'tasks']) {
await page.getByText(root, { exact: true }).waitFor({ timeout: 30000 });
}
const token = await page.evaluate(() => window.localStorage.getItem('beaver_access_token') || '');
if (!token) {
throw new Error('No Beaver access token found after handoff');
}
const uploadName = `smoke-${Date.now()}.txt`;
const uploadBody = `hello from Beaver smoke ${username}`;
const uploadResult = await page.evaluate(async ({ token, uploadName, uploadBody }) => {
const form = new FormData();
form.set('path', 'uploads');
form.set('file', new File([uploadBody], uploadName, { type: 'text/plain' }));
const response = await fetch('/api/user-files/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
});
return { status: response.status, body: await response.json().catch(() => ({})) };
}, { token, uploadName, uploadBody });
if (uploadResult.status !== 200 || uploadResult.body.path !== `uploads/${uploadName}`) {
throw new Error(`User file upload failed: ${JSON.stringify(uploadResult)}`);
}
const apiVisibility = await page.evaluate(async ({ token }) => {
const headers = { Authorization: `Bearer ${token}` };
const [status, root] = await Promise.all([
fetch('/api/user-files/status', { headers }).then((response) => response.json()),
fetch('/api/user-files/browse', { headers }).then((response) => response.json()),
]);
return JSON.stringify({ status, root });
}, { token });
for (const forbidden of ['access_key', 'secret_key', 'bucket', 'namespace', 'object_prefix']) {
if (apiVisibility.includes(forbidden)) {
throw new Error(`User file API leaked storage field: ${forbidden}`);
}
}
const minioVerified = verifyMinio ? verifyUploadedObjectInMinio(username, uploadName) : false;
const finalUrl = page.url();
await page.screenshot({ path: process.env.BEAVER_SMOKE_SCREENSHOT || '/tmp/beaver-auth-files-smoke.png', fullPage: true });
console.log(JSON.stringify({
ok: true,
username,
email,
finalUrl,
roots: ['uploads', 'outputs', 'shared', 'tasks'],
uploadedPath: `uploads/${uploadName}`,
minioVerified,
failedResponses,
failedRequests,
}, null, 2));
} catch (error) {
await page.screenshot({ path: process.env.BEAVER_SMOKE_ERROR_SCREENSHOT || '/tmp/beaver-auth-files-smoke-error.png', fullPage: true }).catch(() => {});
console.error(JSON.stringify({
ok: false,
username,
email,
currentUrl: page.url(),
error: String(error),
failedResponses,
failedRequests,
}, null, 2));
process.exitCode = 1;
} finally {
await browser.close();
}
}
function verifyUploadedObjectInMinio(username, uploadName) {
const endpoint = process.env.BEAVER_SMOKE_MINIO_ENDPOINT || 'http://127.0.0.1:9000';
const accessKey = process.env.BEAVER_SMOKE_MINIO_ACCESS_KEY || process.env.BEAVER_MINIO_ROOT_USER || '';
const secretKey = process.env.BEAVER_SMOKE_MINIO_SECRET_KEY || process.env.BEAVER_MINIO_ROOT_PASSWORD || '';
const bucket = process.env.BEAVER_SMOKE_MINIO_BUCKET || process.env.BEAVER_USER_FILES_BUCKET || 'beaver-user-files';
const network = process.env.BEAVER_SMOKE_MINIO_NETWORK || '';
if (!accessKey || !secretKey) {
throw new Error('MinIO verification requested but access key or secret key is missing');
}
const containerArgs = ['run', '--rm', '--entrypoint', '/bin/sh'];
if (network) {
containerArgs.push('--network', network);
}
containerArgs.push(
'minio/mc:latest',
'-lc',
[
`mc alias set beaver ${shellQuote(endpoint)} ${shellQuote(accessKey)} ${shellQuote(secretKey)} >/dev/null`,
`mc stat ${shellQuote(`beaver/${bucket}/users/${username}/uploads/${uploadName}`)} >/dev/null`,
].join(' && '),
);
execFileSync('docker', containerArgs, { stdio: 'pipe' });
return true;
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}
await main();

16
scripts/smoke-auth-files.sh Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PLAYWRIGHT_WORKDIR="${BEAVER_PLAYWRIGHT_WORKDIR:-/tmp/beaver-playwright-smoke}"
PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION:-1.60.0}"
mkdir -p "$PLAYWRIGHT_WORKDIR"
if ! NODE_PATH="${PLAYWRIGHT_WORKDIR}/node_modules${NODE_PATH:+:${NODE_PATH}}" node -e "require.resolve('playwright')" >/dev/null 2>&1; then
npm install --prefix "$PLAYWRIGHT_WORKDIR" --no-save "playwright@${PLAYWRIGHT_VERSION}"
fi
export NODE_PATH="${PLAYWRIGHT_WORKDIR}/node_modules${NODE_PATH:+:${NODE_PATH}}"
exec node "${PROJECT_ROOT}/scripts/smoke-auth-files.mjs"

View File

@ -0,0 +1,395 @@
#!/usr/bin/env node
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const require = createRequire(import.meta.url);
const { chromium } = require('playwright');
const now = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
const username = process.env.BEAVER_VALIDATE_USERNAME || `fsauto${now}`;
const password = process.env.BEAVER_VALIDATE_PASSWORD || 'TestPass123!';
const email = process.env.BEAVER_VALIDATE_EMAIL || `${username}@example.test`;
const portalUrl = (process.env.BEAVER_VALIDATE_PORTAL_URL || 'http://127.0.0.1:3081').replace(/\/$/, '');
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined;
const hostResolverRules = process.env.BEAVER_VALIDATE_HOST_RESOLVER_RULES || process.env.BEAVER_SMOKE_HOST_RESOLVER_RULES || '';
const headless = process.env.BEAVER_VALIDATE_HEADLESS !== '0';
const verifyMinio = process.env.BEAVER_VALIDATE_VERIFY_MINIO === '1';
const cleanupEnabled = process.env.BEAVER_VALIDATE_CLEANUP !== '0';
const screenshotDir = process.env.BEAVER_VALIDATE_SCREENSHOT_DIR || '/tmp';
const reportPath = process.env.BEAVER_VALIDATE_REPORT || '';
const taskScope = `ui-delete-${Date.now()}`;
const deleteTargets = [
{ root: 'uploads', path: 'uploads/delete-me-uploads.txt', dir: 'uploads', name: 'delete-me-uploads.txt' },
{ root: 'outputs', path: 'outputs/delete-me-outputs.txt', dir: 'outputs', name: 'delete-me-outputs.txt' },
{ root: 'shared', path: 'shared/delete-me-shared.txt', dir: 'shared', name: 'delete-me-shared.txt' },
{ root: 'tasks', path: `tasks/${taskScope}/delete-me-tasks.txt`, dir: `tasks/${taskScope}`, name: 'delete-me-tasks.txt' },
];
const pageChecks = [
{ name: 'Files', path: '/files', marker: /文件管理|Files/ },
{ name: 'Tasks', path: '/tasks', marker: /任务|Tasks/ },
{ name: 'Tools/MCP', path: '/mcp', marker: /工具|Tools/ },
{ name: 'Logs', path: '/logs', marker: /运行日志|Runtime Logs/ },
{ name: 'Agents', path: '/agents', marker: /智能体|Agents/ },
{ name: 'Skills', path: '/skills', marker: /技能|Skills/ },
];
const launchArgs = [];
if (hostResolverRules) {
launchArgs.push(`--host-resolver-rules=${hostResolverRules}`);
}
const failedResponses = [];
const failedRequests = [];
const consoleErrors = [];
const pageEvidence = [];
function remember(list, value) {
list.push(value);
if (list.length > 80) {
list.shift();
}
}
function sanitize(value) {
return String(value)
.replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, '$1<redacted>')
.replace(/(access[_-]?key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>')
.replace(/(secret[_-]?key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>')
.replace(/(password["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>');
}
async function clickFirstVisible(locators) {
for (const locator of locators) {
if (await locator.first().isVisible().catch(() => false)) {
await locator.first().click();
return true;
}
}
return false;
}
async function registerAndHandoff(page) {
const registerUrl = new URL('/register', portalUrl);
registerUrl.searchParams.set('next', '/files');
await page.goto(registerUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.locator('#username').fill(username);
await page.locator('#email').fill(email);
await page.locator('#password').fill(password);
await page.locator('#confirmPassword').fill(password);
await page.locator('form').first().locator('button[type="submit"]').click();
await page.waitForResponse(
(response) => response.url().includes('/api/runtime/register'),
{ timeout: 180000 },
);
await page.getByText(/配置模型|Model Setup/).waitFor({ timeout: 180000 });
const skipped = await clickFirstVisible([
page.getByRole('button', { name: /跳过|Skip/i }),
page.locator('button.secondary-button'),
]);
if (!skipped) {
throw new Error('Model setup skip button was not found');
}
await page.waitForURL(/\/files$/, { timeout: 90000 });
await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {});
await page.getByText(/文件管理|Files/).waitFor({ timeout: 30000 });
const token = await page.evaluate(() => window.localStorage.getItem('beaver_access_token') || '');
if (!token) {
throw new Error('No Beaver access token found after app handoff');
}
const me = await apiFetch(page, token, '/api/auth/me');
if (me.status !== 200 || me.body?.username !== username) {
throw new Error(`App auth confirmation failed: ${JSON.stringify(me)}`);
}
assertAppHost(page.url());
return token;
}
function assertAppHost(url) {
const current = new URL(url);
const portal = new URL(portalUrl);
if (current.host === portal.host || current.pathname.startsWith('/login') || current.pathname.startsWith('/register')) {
throw new Error(`Browser is not on the app instance after handoff: ${url}`);
}
}
async function apiFetch(page, token, path, options = {}) {
return page.evaluate(async ({ token, path, options }) => {
const response = await fetch(path, {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${token}`,
},
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: response.status, body };
}, { token, path, options });
}
async function createDeleteTargets(page, token) {
const result = await page.evaluate(async ({ token, deleteTargets, taskScope }) => {
const headers = { Authorization: `Bearer ${token}` };
const mkdir = await fetch(`/api/user-files/mkdir?path=${encodeURIComponent(`tasks/${taskScope}`)}`, {
method: 'POST',
headers,
});
if (!mkdir.ok && mkdir.status !== 409) {
throw new Error(`mkdir failed: ${mkdir.status}`);
}
const uploaded = [];
for (const target of deleteTargets) {
const form = new FormData();
form.set('path', target.dir);
form.set('file', new File([`delete target ${target.path}`], target.name, { type: 'text/plain' }));
const response = await fetch('/api/user-files/upload', {
method: 'POST',
headers,
body: form,
});
const body = await response.json().catch(() => ({}));
if (!response.ok || body.path !== target.path) {
throw new Error(`upload failed for ${target.path}: ${response.status} ${JSON.stringify(body)}`);
}
uploaded.push(body.path);
}
return uploaded;
}, { token, deleteTargets, taskScope });
if (result.length !== deleteTargets.length) {
throw new Error(`Unexpected upload target count: ${result.length}`);
}
}
async function waitForRootEntries(page) {
await page.getByText(/文件管理|Files/).waitFor({ timeout: 30000 });
for (const root of ['uploads', 'outputs', 'shared', 'tasks']) {
await page.locator('button').filter({ hasText: root }).first().waitFor({ state: 'visible', timeout: 30000 });
}
}
async function navigateToRoot(page) {
await page.goto(`${new URL(page.url()).origin}/files`, { waitUntil: 'domcontentloaded', timeout: 30000 });
await waitForRootEntries(page);
}
async function openDirectory(page, name) {
const row = page.locator('button').filter({ hasText: name }).first();
await row.waitFor({ state: 'visible', timeout: 30000 });
await row.click();
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
}
async function deleteFromFilesPage(page, target) {
await navigateToRoot(page);
await openDirectory(page, target.root);
if (target.root === 'tasks') {
await openDirectory(page, taskScope);
}
const row = page.locator('button').filter({ hasText: target.name }).first();
await row.waitFor({ state: 'visible', timeout: 30000 });
await row.hover();
await row.locator('[title="Delete"], [title="删除"]').last().click({ force: true });
await page.waitForFunction(
(name) => !document.body.innerText.includes(name),
target.name,
{ timeout: 30000 },
);
}
async function verifyDeletedViaApi(page, token) {
for (const target of deleteTargets) {
const preview = await apiFetch(page, token, `/api/user-files/preview?path=${encodeURIComponent(target.path)}`);
if (preview.status !== 404) {
throw new Error(`Expected preview 404 after UI delete for ${target.path}, got ${preview.status}`);
}
}
const roots = await apiFetch(page, token, '/api/user-files/browse');
if (roots.status !== 200) {
throw new Error(`Root browse failed after UI deletion: ${roots.status}`);
}
const names = new Set((roots.body?.items || []).map((item) => item.name));
for (const root of ['uploads', 'outputs', 'shared', 'tasks']) {
if (!names.has(root)) {
throw new Error(`Virtual root disappeared after UI deletion: ${root}`);
}
}
}
function verifyDeletedInMinio() {
if (!verifyMinio) {
return false;
}
const endpoint = process.env.BEAVER_VALIDATE_MINIO_ENDPOINT || process.env.BEAVER_SMOKE_MINIO_ENDPOINT || 'http://127.0.0.1:9000';
const accessKey = process.env.BEAVER_VALIDATE_MINIO_ACCESS_KEY || process.env.BEAVER_SMOKE_MINIO_ACCESS_KEY || process.env.BEAVER_MINIO_ROOT_USER || '';
const secretKey = process.env.BEAVER_VALIDATE_MINIO_SECRET_KEY || process.env.BEAVER_SMOKE_MINIO_SECRET_KEY || process.env.BEAVER_MINIO_ROOT_PASSWORD || '';
const bucket = process.env.BEAVER_VALIDATE_MINIO_BUCKET || process.env.BEAVER_SMOKE_MINIO_BUCKET || process.env.BEAVER_USER_FILES_BUCKET || 'beaver-user-files';
const network = process.env.BEAVER_VALIDATE_MINIO_NETWORK || process.env.BEAVER_SMOKE_MINIO_NETWORK || '';
if (!accessKey || !secretKey) {
throw new Error('MinIO verification requested but access key or secret key is missing');
}
const checks = deleteTargets
.map((target) => `if mc stat ${shellQuote(`beaver/${bucket}/users/${username}/${target.path}`)} >/dev/null 2>&1; then echo ${shellQuote(`object still exists: ${target.path}`)}; exit 1; fi`)
.join(' && ');
const containerArgs = ['run', '--rm', '--entrypoint', '/bin/sh'];
if (network) {
containerArgs.push('--network', network);
}
containerArgs.push(
'minio/mc:latest',
'-lc',
[
`mc alias set beaver ${shellQuote(endpoint)} ${shellQuote(accessKey)} ${shellQuote(secretKey)} >/dev/null`,
checks,
].join(' && '),
);
execFileSync('docker', containerArgs, { stdio: 'pipe' });
return true;
}
async function checkPage(page, appOrigin, check) {
const beforeResponseCount = failedResponses.length;
const beforeConsoleCount = consoleErrors.length;
await page.goto(`${appOrigin}${check.path}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.getByText(check.marker).first().waitFor({ timeout: 30000 });
if (check.path === '/agents') {
const subagentTab = page.getByRole('tab', { name: /Persistent sub-agents|Persistent Sub-Agents|Sub-Agent/i }).first();
if (await subagentTab.isVisible().catch(() => false)) {
await subagentTab.click();
await page.getByText(/Sub-Agent|sub-agent|子智能体/i).first().waitFor({ timeout: 30000 });
}
}
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
assertAppHost(page.url());
const newResponses = failedResponses.slice(beforeResponseCount);
const newConsoleErrors = consoleErrors.slice(beforeConsoleCount);
const hardFailures = newResponses.filter((entry) => entry.status >= 500 || (entry.url.includes('/api/user-files/') && entry.status >= 400));
if (hardFailures.length) {
throw new Error(`Page ${check.name} had failing responses: ${hardFailures.map((item) => `${item.status} ${item.url}`).join('; ')}`);
}
const fileConsoleErrors = newConsoleErrors.filter((entry) => /user[-_ ]?files|filesystem|minio|storage/i.test(entry.text));
if (fileConsoleErrors.length) {
throw new Error(`Page ${check.name} had file-system-related console errors: ${fileConsoleErrors.map((item) => item.text).join('; ')}`);
}
pageEvidence.push({ name: check.name, path: check.path, finalUrl: page.url(), failedResponseCount: newResponses.length });
}
async function cleanupDisposableUser() {
const token = process.env.BEAVER_VALIDATE_DEPLOY_TOKEN || process.env.DEPLOY_CONTROL_API_TOKEN || '';
const deployUrl = (process.env.BEAVER_VALIDATE_DEPLOY_CONTROL_URL || process.env.DEPLOY_CONTROL_URL || 'http://127.0.0.1:8090').replace(/\/$/, '');
if (!cleanupEnabled || !token) {
return { attempted: false, reason: cleanupEnabled ? 'missing deploy-control token' : 'disabled' };
}
const response = await fetch(`${deployUrl}/api/instances/${encodeURIComponent(username)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'X-Purge-Data': '1',
'X-Purge-User-Files': '1',
},
});
const body = await response.json().catch(() => ({}));
return { attempted: true, status: response.status, ok: response.ok, body };
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}
async function main() {
const browser = await chromium.launch({ headless, executablePath, args: launchArgs });
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
page.on('dialog', (dialog) => dialog.accept());
page.on('response', (response) => {
if (response.status() >= 400) {
remember(failedResponses, { status: response.status(), url: response.url() });
}
});
page.on('requestfailed', (request) => {
remember(failedRequests, { url: request.url(), error: request.failure()?.errorText || '' });
});
page.on('console', (message) => {
if (message.type() === 'error') {
remember(consoleErrors, { text: sanitize(message.text()) });
}
});
let cleanup = { attempted: false, reason: 'not reached' };
try {
const token = await registerAndHandoff(page);
const appOrigin = new URL(page.url()).origin;
await waitForRootEntries(page);
await createDeleteTargets(page, token);
for (const target of deleteTargets) {
await deleteFromFilesPage(page, target);
}
await verifyDeletedViaApi(page, token);
const minioVerified = verifyDeletedInMinio();
for (const check of pageChecks) {
await checkPage(page, appOrigin, check);
}
const finalScreenshot = `${screenshotDir}/beaver-filesystem-validation-${username}.png`;
await page.screenshot({ path: finalScreenshot, fullPage: true }).catch(() => {});
cleanup = await cleanupDisposableUser();
const result = {
ok: true,
username,
email,
deletedRoots: deleteTargets.map((target) => target.root),
deletedPaths: deleteTargets.map((target) => target.path),
checkedPages: pageEvidence,
minioVerified,
cleanup,
screenshot: finalScreenshot,
failedResponses,
failedRequests,
consoleErrors,
};
if (reportPath) {
writeFileSync(reportPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
}
console.log(JSON.stringify(result, null, 2));
} catch (error) {
const errorScreenshot = `${screenshotDir}/beaver-filesystem-validation-${username}-error.png`;
await page.screenshot({ path: errorScreenshot, fullPage: true }).catch(() => {});
cleanup = await cleanupDisposableUser().catch((cleanupError) => ({ attempted: true, ok: false, error: sanitize(cleanupError) }));
const result = {
ok: false,
username,
email,
currentUrl: page.url(),
error: sanitize(error),
cleanup,
screenshot: errorScreenshot,
failedResponses,
failedRequests,
consoleErrors,
};
if (reportPath) {
writeFileSync(reportPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
}
console.error(JSON.stringify(result, null, 2));
process.exitCode = 1;
} finally {
await browser.close();
}
}
await main();

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PLAYWRIGHT_WORKDIR="${BEAVER_PLAYWRIGHT_WORKDIR:-/tmp/beaver-playwright-smoke}"
PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION:-1.60.0}"
mkdir -p "$PLAYWRIGHT_WORKDIR"
if ! NODE_PATH="${PLAYWRIGHT_WORKDIR}/node_modules${NODE_PATH:+:${NODE_PATH}}" node -e "require.resolve('playwright')" >/dev/null 2>&1; then
npm install --prefix "$PLAYWRIGHT_WORKDIR" --no-save "playwright@${PLAYWRIGHT_VERSION}"
fi
export NODE_PATH="${PLAYWRIGHT_WORKDIR}/node_modules${NODE_PATH:+:${NODE_PATH}}"
exec node "${PROJECT_ROOT}/scripts/validate-filesystem-automation.mjs"

View File

@ -1,6 +1,6 @@
# Beaver Project 域名配置指引 # Beaver Project 域名配置指引
这份文档说明如何从本机测试域名 `127.0.0.1.nip.io` 切换到正式域名。 这份文档说明如何从本机测试域名 `localhost` 子域名切换到正式域名。
核心结论: 核心结论:
@ -105,7 +105,7 @@ proxy_set_header X-Forwarded-Proto $scheme;
本机测试: 本机测试:
```bash ```bash
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io export BEAVER_BASE_DOMAIN=localhost
``` ```
`deploy-control` `deploy-control`
@ -119,7 +119,7 @@ export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
生成实例地址: 生成实例地址:
```text ```text
http://alice.127.0.0.1.nip.io:8088 http://alice.localhost:8088
``` ```
正式 HTTPS 正式 HTTPS
@ -322,7 +322,7 @@ portal.example.com -> 3081
如果只是本机验证完整链路,继续使用: 如果只是本机验证完整链路,继续使用:
```bash ```bash
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io export BEAVER_BASE_DOMAIN=localhost
``` ```
它已经足够测试: 它已经足够测试:

View File

@ -12,7 +12,7 @@
- 浏览器能打开 `http://127.0.0.1:3081/register` - 浏览器能打开 `http://127.0.0.1:3081/register`
- 注册账号后自动创建专属实例 - 注册账号后自动创建专属实例
- 浏览器跳转到 `http://<slug>.127.0.0.1.nip.io:8088` - 浏览器跳转到 `http://<slug>.localhost:8088`
如果你只单独启动某个前端页面,页面可以打开,但注册、登录、创建实例这些动作不一定能通。 如果你只单独启动某个前端页面,页面可以打开,但注册、登录、创建实例这些动作不一定能通。
@ -58,14 +58,21 @@ pwd
## 2. 准备本机测试变量 ## 2. 准备本机测试变量
本机测试推荐用 `127.0.0.1.nip.io`。例如: 本机测试推荐用 `localhost` 子域名。例如:
```text ```text
alice.127.0.0.1.nip.io -> 127.0.0.1 alice.localhost -> 127.0.0.1 / ::1
``` ```
这样 `router-proxy` 可以按子域名区分不同实例。 这样 `router-proxy` 可以按子域名区分不同实例。
注意:
- `*.localhost` 只适合在部署机器本机浏览器里测试。
- 如果别的电脑访问 `alice.localhost`,它会指向那台电脑自己,不会指向 Beaver 服务器。
- 局域网、服务器或正式环境必须把 `BEAVER_BASE_DOMAIN` 改成客户端能解析到 Beaver 服务器的域名,例如 `apps.example.com`
- 旧版文档使用过 `127.0.0.1.nip.io`。它本来应该按域名里的 IP 解析到 `127.0.0.1`,但在部分 VPN、代理、DNS 网关或内网安全设备环境下会被改写到 `198.18.0.0/15` 这类假 IP 段,导致浏览器连不上本机服务。
直接执行: 直接执行:
```bash ```bash
@ -76,11 +83,17 @@ export BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)" export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)" export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io export BEAVER_BASE_DOMAIN=localhost
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090' export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090' export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
export BEAVER_MINIO_ROOT_USER='beaver-minio-admin'
export BEAVER_MINIO_ROOT_PASSWORD="$(openssl rand -hex 32)"
export BEAVER_USER_FILES_BUCKET='beaver-user-files'
export BEAVER_USER_FILES_MINIO_ENDPOINT='beaver-minio:9000'
export BEAVER_USER_FILES_MAX_UPLOAD_BYTES=$((5 * 1024 * 1024 * 1024))
export BEAVER_OUTLOOK_MCP_URL='' export BEAVER_OUTLOOK_MCP_URL=''
export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp' export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
``` ```
@ -93,12 +106,32 @@ export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
| `BEAVER_NET` | 所有容器共用的 Docker network | | `BEAVER_NET` | 所有容器共用的 Docker network |
| `BEAVER_DEPLOY_TOKEN` | `auth-portal` / `authz-service``deploy-control` 的 token | | `BEAVER_DEPLOY_TOKEN` | `auth-portal` / `authz-service``deploy-control` 的 token |
| `BEAVER_AUTHZ_INTERNAL_TOKEN` | AuthZ 内部接口 token | | `BEAVER_AUTHZ_INTERNAL_TOKEN` | AuthZ 内部接口 token |
| `BEAVER_BASE_DOMAIN` | 新实例的基域名 | | `BEAVER_BASE_DOMAIN` | 新实例的基域名;本机测试用 `localhost`,服务器部署用真实域名 |
| `BEAVER_AUTHZ_URL` | 容器网络内访问 AuthZ 的地址 | | `BEAVER_AUTHZ_URL` | 容器网络内访问 AuthZ 的地址 |
| `BEAVER_DEPLOY_URL` | 容器网络内访问 deploy-control 的地址 | | `BEAVER_DEPLOY_URL` | 容器网络内访问 deploy-control 的地址 |
| `BEAVER_MINIO_ROOT_USER` / `BEAVER_MINIO_ROOT_PASSWORD` | 只给 provisioning 组件使用的 MinIO 管理凭据 |
| `BEAVER_USER_FILES_BUCKET` | 用户文件系统共用 bucket默认 `beaver-user-files` |
| `BEAVER_USER_FILES_MINIO_ENDPOINT` | 容器网络内访问 MinIO API 的地址 |
| `BEAVER_USER_FILES_MAX_UPLOAD_BYTES` | 用户文件系统上传上限,默认 5GB聊天附件和 workspace 上传仍保留当前小文件限制 |
| `BEAVER_OUTLOOK_MCP_URL` | 可选 Outlook MCP HTTP 地址 | | `BEAVER_OUTLOOK_MCP_URL` | 可选 Outlook MCP HTTP 地址 |
| `BEAVER_OUTLOOK_MCP_SERVER_ID` | Outlook MCP server id默认 `outlook_mcp` | | `BEAVER_OUTLOOK_MCP_SERVER_ID` | Outlook MCP server id默认 `outlook_mcp` |
如果接入外部正式 MinIO不需要启动本地 `beaver-minio`。把上面的 MinIO 变量改成正式服务即可:
```bash
export BEAVER_MINIO_ROOT_USER='<minio-admin-access-key>'
export BEAVER_MINIO_ROOT_PASSWORD='<minio-admin-secret-key>'
export BEAVER_USER_FILES_BUCKET='beaver-user-files-formal-test'
export BEAVER_USER_FILES_MINIO_ENDPOINT='10.6.80.98:19000'
```
注意:
- `BEAVER_USER_FILES_MINIO_ENDPOINT` 是给 Python MinIO SDK 用的 S3 API endpoint格式是 `host:port`,不要带 `http://`
- 操作员用 `mc``curl` 验证时才写成 `http://10.6.80.98:19000`
- MinIO Console 端口不是 S3 API 端口。例如 `19001` 如果返回 MinIO Console 页面,就不能填进 `USER_FILES_MINIO_ENDPOINT`
- 这个管理员账号只给 AuthZ provisioning 使用,用于创建共享 bucket、scoped user 和 scoped policy不要暴露给前端或普通用户。
`BEAVER_AUTHZ_URL``BEAVER_DEPLOY_URL` 必须带协议头。正确写法: `BEAVER_AUTHZ_URL``BEAVER_DEPLOY_URL` 必须带协议头。正确写法:
```text ```text
@ -126,11 +159,37 @@ beaver-deploy-control:8090
- `beaver-authz-service` - `beaver-authz-service`
- `beaver-auth-portal` - `beaver-auth-portal`
如果改的是 `BEAVER_BASE_DOMAIN`,还要重启 `beaver-deploy-control`。这个变量只影响之后新创建的实例;已经创建过的实例 URL 已经写入 `app-instance/runtime/registry/instances.json`,不会自动改成新域名。
### 非本机访问怎么配置域名
如果 Beaver 部署在服务器上,而用户从其他机器访问,不要使用 `localhost`。推荐准备一个真实域名,并把通配子域名解析到服务器,例如:
```text
portal.example.com -> Beaver 服务器
*.apps.example.com -> Beaver 服务器
```
然后使用:
```bash
export BEAVER_BASE_DOMAIN=apps.example.com
```
新实例 URL 会变成:
```text
http://alice.apps.example.com:8088
```
如果外层 Nginx/Caddy/负载均衡已经把 `https://*.apps.example.com` 转发到 `router-proxy`,正式环境通常还会把 `DEPLOY_PUBLIC_SCHEME` 改为 `https`,并把 `DEPLOY_PUBLIC_PORT` 改成 `443`
## 3. 创建运行目录 ## 3. 创建运行目录
```bash ```bash
mkdir -p \ mkdir -p \
"$PROJECT_ROOT/authz-service/runtime/data" \ "$PROJECT_ROOT/authz-service/runtime/data" \
"$PROJECT_ROOT/minio/runtime/data" \
"$PROJECT_ROOT/app-instance/runtime/instances" \ "$PROJECT_ROOT/app-instance/runtime/instances" \
"$PROJECT_ROOT/app-instance/runtime/registry" \ "$PROJECT_ROOT/app-instance/runtime/registry" \
"$PROJECT_ROOT/router-proxy/runtime/conf.d" "$PROJECT_ROOT/router-proxy/runtime/conf.d"
@ -139,6 +198,7 @@ mkdir -p \
这些目录保存: 这些目录保存:
- AuthZ 数据 - AuthZ 数据
- MinIO 对象数据
- 实例注册表 - 实例注册表
- 每个用户实例的配置和数据 - 每个用户实例的配置和数据
- `router-proxy` 生成的路由文件 - `router-proxy` 生成的路由文件
@ -183,16 +243,57 @@ PROXY_HTTP_PORT=8088 \
实例统一入口: 实例统一入口:
```text ```text
http://<slug>.127.0.0.1.nip.io:8088 http://<slug>.localhost:8088
``` ```
示例: 示例:
```text ```text
http://alice.127.0.0.1.nip.io:8088 http://alice.localhost:8088
``` ```
## 7. 启动 authz-service 这里的 `localhost` 示例只表示本机测试。服务器部署时应替换为上面配置的真实基域名,例如:
```text
http://alice.apps.example.com:8088
```
## 7. 启动 MinIO
MinIO 是用户文件系统的后端实现细节。用户和前端不会看到 bucket、access key 或 prefixBeaver 只通过 `/api/user-files/*` 暴露个人智能体文件系统。
如果使用外部正式 MinIO可以跳过本节本地 MinIO 容器启动,直接进入 `authz-service` 启动步骤。
```bash
docker rm -f beaver-minio >/dev/null 2>&1 || true
docker run -d \
--name beaver-minio \
--restart unless-stopped \
--network "$BEAVER_NET" \
-p 9000:9000 \
-p 9001:9001 \
-v "$PROJECT_ROOT/minio/runtime/data:/data" \
-e MINIO_ROOT_USER="$BEAVER_MINIO_ROOT_USER" \
-e MINIO_ROOT_PASSWORD="$BEAVER_MINIO_ROOT_PASSWORD" \
minio/minio:latest server /data --console-address ":9001"
```
用户文件采用共享 bucket + 用户 namespace
```text
bucket: beaver-user-files
namespace: users/{backend_id}
example object: users/alice/uploads/report.pdf
```
每个 backend/user 会由 AuthZ provisioning 生成 scoped MinIO 凭据policy 只允许访问自己的 `users/{backend_id}/*` prefix。
用户文件路径必须使用相对的虚拟根路径,例如 `uploads/input.txt``outputs/report.md``shared/profile.json``tasks/<task_id>/draft.md``/uploads/input.txt` 这类 leading-slash absolute-style path 会被拒绝,不会再被规范化成当前用户的 `uploads/input.txt`
用户文件上传由 Beaver 后端代理到 MinIO不暴露 bucket、prefix 或凭据。当前默认允许最大 5GB 的用户文件上传,业务上限由 app-instance 后端环境变量 `BEAVER_USER_FILES_MAX_UPLOAD_BYTES` 控制;反向代理默认 `client_max_body_size` 已提高到 5GB。MinIO 本身支持大对象和 multipart 上传,但 agent 对超大文件的读取/处理能力仍需要按具体任务另行验证。
## 8. 启动 authz-service
```bash ```bash
docker rm -f beaver-authz-service >/dev/null 2>&1 || true docker rm -f beaver-authz-service >/dev/null 2>&1 || true
@ -207,6 +308,13 @@ docker run -d \
-e AUTHZ_INTERNAL_TOKEN="$BEAVER_AUTHZ_INTERNAL_TOKEN" \ -e AUTHZ_INTERNAL_TOKEN="$BEAVER_AUTHZ_INTERNAL_TOKEN" \
-e DEPLOY_API_BASE_URL="$BEAVER_DEPLOY_URL" \ -e DEPLOY_API_BASE_URL="$BEAVER_DEPLOY_URL" \
-e DEPLOY_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \ -e DEPLOY_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
-e USER_FILES_MINIO_PROVISIONING_ENABLED=1 \
-e USER_FILES_MINIO_ENDPOINT="$BEAVER_USER_FILES_MINIO_ENDPOINT" \
-e USER_FILES_MINIO_PUBLIC_ENDPOINT="$BEAVER_USER_FILES_MINIO_ENDPOINT" \
-e USER_FILES_MINIO_ADMIN_ACCESS_KEY="$BEAVER_MINIO_ROOT_USER" \
-e USER_FILES_MINIO_ADMIN_SECRET_KEY="$BEAVER_MINIO_ROOT_PASSWORD" \
-e USER_FILES_MINIO_BUCKET="$BEAVER_USER_FILES_BUCKET" \
-e USER_FILES_MINIO_SECURE=0 \
beaver/authz-service:latest beaver/authz-service:latest
``` ```
@ -215,15 +323,16 @@ docker run -d \
- `AUTHZ_ISSUER` 在当前部署里要写 `http://beaver-authz-service:19090` - `AUTHZ_ISSUER` 在当前部署里要写 `http://beaver-authz-service:19090`
- 不要写 `http://127.0.0.1:19090` - 不要写 `http://127.0.0.1:19090`
- 新创建的 `app-instance` 容器要通过 Docker network 访问 AuthZ - 新创建的 `app-instance` 容器要通过 Docker network 访问 AuthZ
- `USER_FILES_MINIO_*` 只用于 AuthZ provisioning 创建 bucket、用户、policy并把 scoped settings 存入 AuthZ普通用户不会接触这些配置。
检查关键环境变量: 检查关键环境变量:
```bash ```bash
docker inspect beaver-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' \ docker inspect beaver-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' \
| egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)=' | egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL|USER_FILES_MINIO_)='
``` ```
## 8. 启动 deploy-control ## 9. 启动 deploy-control
`deploy-control` 会挂载 Docker socket再创建新的 `app-instance` 容器。这里最容易错的是路径挂载: `deploy-control` 会挂载 Docker socket再创建新的 `app-instance` 容器。这里最容易错的是路径挂载:
@ -252,8 +361,10 @@ docker run -d \
-e APP_INSTANCE_IMAGE="beaver/app-instance:latest" \ -e APP_INSTANCE_IMAGE="beaver/app-instance:latest" \
-e APP_INSTANCE_NETWORK_NAME="$BEAVER_NET" \ -e APP_INSTANCE_NETWORK_NAME="$BEAVER_NET" \
-e DEFAULT_AUTHZ_BASE_URL="$BEAVER_AUTHZ_URL" \ -e DEFAULT_AUTHZ_BASE_URL="$BEAVER_AUTHZ_URL" \
-e DEFAULT_AUTHZ_INTERNAL_TOKEN="$BEAVER_AUTHZ_INTERNAL_TOKEN" \
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$BEAVER_OUTLOOK_MCP_URL" \ -e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$BEAVER_OUTLOOK_MCP_URL" \
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$BEAVER_OUTLOOK_MCP_SERVER_ID" \ -e DEFAULT_OUTLOOK_MCP_SERVER_ID="$BEAVER_OUTLOOK_MCP_SERVER_ID" \
-e DEFAULT_USER_FILES_MAX_UPLOAD_BYTES="$BEAVER_USER_FILES_MAX_UPLOAD_BYTES" \
-e DEPLOY_PUBLIC_SCHEME="http" \ -e DEPLOY_PUBLIC_SCHEME="http" \
-e DEPLOY_PUBLIC_BASE_DOMAIN="$BEAVER_BASE_DOMAIN" \ -e DEPLOY_PUBLIC_BASE_DOMAIN="$BEAVER_BASE_DOMAIN" \
-e DEPLOY_PUBLIC_PORT="8088" \ -e DEPLOY_PUBLIC_PORT="8088" \
@ -261,9 +372,13 @@ docker run -d \
beaver/deploy-control:latest beaver/deploy-control:latest
``` ```
`DEPLOY_PUBLIC_BASE_DOMAIN` 来自 `BEAVER_BASE_DOMAIN`。本机测试时可以是 `localhost`;如果要让其他设备访问,必须换成它们能解析到 Beaver 服务器的真实域名。修改后需要重启 `beaver-deploy-control`,并重新创建实例或手动更新 registry 后重载 `router-proxy`
当前版本创建实例时会传 `--skip-provider-config`,也就是先不写 provider、model 或 API key。注册成功后`auth-portal` 会进入模型配置引导页,再调用 `deploy-control /api/instances/configure-provider` 写入该实例的 `config.json` 并重启容器。 当前版本创建实例时会传 `--skip-provider-config`,也就是先不写 provider、model 或 API key。注册成功后`auth-portal` 会进入模型配置引导页,再调用 `deploy-control /api/instances/configure-provider` 写入该实例的 `config.json` 并重启容器。
## 9. 启动 auth-portal `DEFAULT_AUTHZ_INTERNAL_TOKEN` 会写入新建 app-instance 的后端 runtime env用于 app-instance 后端读取自己的 internal MinIO settings。它不会传给前端。
## 10. 启动 auth-portal
```bash ```bash
docker rm -f beaver-auth-portal >/dev/null 2>&1 || true docker rm -f beaver-auth-portal >/dev/null 2>&1 || true
@ -286,12 +401,13 @@ docker inspect beaver-auth-portal --format '{{range .Config.Env}}{{println .}}{{
| egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)=' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)='
``` ```
## 10. 健康检查 ## 11. 健康检查
```bash ```bash
curl http://127.0.0.1:19090/healthz curl http://127.0.0.1:19090/healthz
curl http://127.0.0.1:8090/healthz curl http://127.0.0.1:8090/healthz
curl -I http://127.0.0.1:3081 curl -I http://127.0.0.1:3081
curl -I http://127.0.0.1:9001
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
docker logs --tail=50 beaver-router-proxy docker logs --tail=50 beaver-router-proxy
``` ```
@ -299,11 +415,12 @@ docker logs --tail=50 beaver-router-proxy
至少应该看到这些容器: 至少应该看到这些容器:
- `beaver-authz-service` - `beaver-authz-service`
- `beaver-minio`
- `beaver-deploy-control` - `beaver-deploy-control`
- `beaver-auth-portal` - `beaver-auth-portal`
- `beaver-router-proxy` - `beaver-router-proxy`
## 11. 浏览器首次测试 ## 12. 浏览器首次测试
打开: 打开:
@ -322,10 +439,140 @@ http://127.0.0.1:3081/register
跳转目标示例: 跳转目标示例:
```text ```text
http://alice.127.0.0.1.nip.io:8088 http://alice.localhost:8088
``` ```
## 12. 确认实例已创建 也可以运行 Playwright 冒烟测试,自动验证注册、跳过模型配置、登录交接、文件页四个根目录、上传文件,并检查 `/api/user-files/*` 不泄露 bucket、namespace 或凭据字段:
```bash
cd "$PROJECT_ROOT"
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/home/worker/.cache/ms-playwright/chromium-1194/chrome-linux/chrome \
./scripts/smoke-auth-files.sh
```
如果要同时验证上传后的对象确实进入 MinIO 的 `users/{backend_id}/uploads/` namespace可以加上 MinIO 校验:
```bash
BEAVER_SMOKE_VERIFY_MINIO=1 \
BEAVER_SMOKE_MINIO_ENDPOINT=http://beaver-minio:9000 \
BEAVER_SMOKE_MINIO_ACCESS_KEY="$BEAVER_MINIO_ROOT_USER" \
BEAVER_SMOKE_MINIO_SECRET_KEY="$BEAVER_MINIO_ROOT_PASSWORD" \
BEAVER_SMOKE_MINIO_BUCKET="$BEAVER_USER_FILES_BUCKET" \
BEAVER_SMOKE_MINIO_NETWORK="$BEAVER_NET" \
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/home/worker/.cache/ms-playwright/chromium-1194/chrome-linux/chrome \
./scripts/smoke-auth-files.sh
```
如果你的机器没有这个 Chromium 路径,可以去掉 `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`,让 Playwright 使用自己安装的浏览器。
外部正式 MinIO 的重复验证流程:
```bash
cd "$PROJECT_ROOT"
BEAVER_SMOKE_VERIFY_MINIO=1 \
BEAVER_SMOKE_MINIO_ENDPOINT=http://10.6.80.98:19000 \
BEAVER_SMOKE_MINIO_ACCESS_KEY="$BEAVER_MINIO_ROOT_USER" \
BEAVER_SMOKE_MINIO_SECRET_KEY="$BEAVER_MINIO_ROOT_PASSWORD" \
BEAVER_SMOKE_MINIO_BUCKET="$BEAVER_USER_FILES_BUCKET" \
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/home/worker/.cache/ms-playwright/chromium-1194/chrome-linux/chrome \
./scripts/smoke-auth-files.sh
```
预期结果:
- 新注册用户的 AuthZ MinIO settings 指向正式 endpoint 和 `users/{backend_id}` namespace。
- 文件页只展示 `uploads``outputs``shared``tasks` 四个根目录。
- `/api/user-files/*` 上传的对象出现在正式 bucket 的 `users/{backend_id}/uploads/` 下。
- 覆盖、下载、删除都由 Beaver API 完成,前端响应不包含 bucket、namespace、access key 或 secret key。
也可以运行更完整的用户文件系统验证自动化,覆盖 Files 页面四根目录 UI 删除、Logs/Subagents 等页面回归、MinIO 缺失对象验证和临时用户清理:
```bash
cd "$PROJECT_ROOT"
BEAVER_VALIDATE_VERIFY_MINIO=1 \
BEAVER_VALIDATE_MINIO_ENDPOINT=http://10.6.80.98:19000 \
BEAVER_VALIDATE_MINIO_ACCESS_KEY="$BEAVER_MINIO_ROOT_USER" \
BEAVER_VALIDATE_MINIO_SECRET_KEY="$BEAVER_MINIO_ROOT_PASSWORD" \
BEAVER_VALIDATE_MINIO_BUCKET="$BEAVER_USER_FILES_BUCKET" \
BEAVER_VALIDATE_DEPLOY_TOKEN="$BEAVER_DEPLOY_TOKEN" \
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/home/worker/.cache/ms-playwright/chromium-1194/chrome-linux/chrome \
./scripts/validate-filesystem-automation.sh
```
### 清理测试账号和 MinIO 用户文件
测试注册、Playwright smoke 或反复部署会创建 app-instance、本地实例目录、AuthZ MinIO settings以及 MinIO 里的 scoped user、policy 和 `users/{backend_id}/` 对象。不要只手工删除 local path否则 MinIO 里会留下无效测试资源。
优先使用清理脚本。默认是 dry-run只列出将要清理的测试账号
```bash
cd "$PROJECT_ROOT"
./scripts/cleanup-test-users.py --username-prefix smoke
```
确认列表无误后执行删除:
```bash
DEPLOY_CONTROL_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
./scripts/cleanup-test-users.py \
--username-prefix smoke \
--purge-data \
--execute
```
这会调用 `deploy-control` 的实例删除接口,并带上:
```text
X-Purge-Data: 1
X-Purge-User-Files: 1
```
其中 `X-Purge-Data` 删除本地实例数据,`X-Purge-User-Files` 让 deploy-control 调 AuthZ 内部接口清理 MinIO 用户文件资源。用户和普通 Files 页面不会看到 bucket、access key、secret key、policy 或 raw prefixMinIO 仍然只是后端实现细节。
如果同一个实例删除请求重复执行deploy-control 会把本地实例已不存在报告为 `already_absent` 的成功 no-op。带 `X-Purge-User-Files: 1` 时,它仍会对同名 backend id 调用 AuthZ 的 best-effort 用户文件清理AuthZ user-file cleanup 本身是幂等的,已不存在的 scoped user、policy、settings 或 namespace 会以 absent/no-op 状态返回。
如果只想清理一个明确实例,也可以直接调用:
```bash
curl -X DELETE "http://127.0.0.1:8090/api/instances/<instance_id>" \
-H "Authorization: Bearer $BEAVER_DEPLOY_TOKEN" \
-H "X-Purge-Data: 1" \
-H "X-Purge-User-Files: 1"
```
如果删除过程中只有一部分成功,可以按下面的方式手工恢复。先设置 MinIO alias
```bash
docker run --rm --network "$BEAVER_NET" --entrypoint /bin/sh minio/mc:latest -lc "
mc alias set beaver http://beaver-minio:9000 '$BEAVER_MINIO_ROOT_USER' '$BEAVER_MINIO_ROOT_PASSWORD'
"
```
然后针对某个 backend id 清理对象、policy 和 user
```bash
BACKEND_ID='<backend_id>'
ACCESS_KEY="beaver-$BACKEND_ID"
POLICY_NAME="beaver-user-files-$BACKEND_ID"
docker run --rm --network "$BEAVER_NET" --entrypoint /bin/sh minio/mc:latest -lc "
mc alias set beaver http://beaver-minio:9000 '$BEAVER_MINIO_ROOT_USER' '$BEAVER_MINIO_ROOT_PASSWORD' >/dev/null &&
mc rm --recursive --force 'beaver/$BEAVER_USER_FILES_BUCKET/users/$BACKEND_ID/' || true &&
mc admin policy detach beaver '$POLICY_NAME' --user '$ACCESS_KEY' || true &&
mc admin user remove beaver '$ACCESS_KEY' || true &&
mc admin policy remove beaver '$POLICY_NAME' || true
"
```
最后删除 AuthZ 里的 MinIO settings
```bash
curl -X DELETE "http://127.0.0.1:19090/backends/$BACKEND_ID/settings/minio"
```
## 13. 确认实例已创建
```bash ```bash
cd "$PROJECT_ROOT/app-instance" cd "$PROJECT_ROOT/app-instance"
@ -343,7 +590,7 @@ docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
- `public_url` - `public_url`
- `instance_host` - `instance_host`
## 13. 只看 auth-portal 页面 ## 14. 只看 auth-portal 页面
如果只想看 Portal 页面,不跑全链路: 如果只想看 Portal 页面,不跑全链路:
@ -361,7 +608,7 @@ http://127.0.0.1:3081
注意:这只能看页面。注册、登录、创建实例仍依赖 `authz-service``deploy-control` 注意:这只能看页面。注册、登录、创建实例仍依赖 `authz-service``deploy-control`
## 14. 常用排错命令 ## 15. 常用排错命令
```bash ```bash
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
@ -396,7 +643,7 @@ docker inspect beaver-auth-portal --format '{{range .Config.Env}}{{println .}}{{
它们都必须是完整 URL不能是空字符串也不能是裸 `host:port` 它们都必须是完整 URL不能是空字符串也不能是裸 `host:port`
## 15. 常见问题 ## 16. 常见问题
### 注册页报 URL 缺少协议 ### 注册页报 URL 缺少协议
@ -447,15 +694,30 @@ $PROJECT_ROOT/router-proxy -> $PROJECT_ROOT/router-proxy
因为 `deploy-control` 会通过宿主机 Docker socket 再创建新容器,传给 Docker 的 bind mount 源路径必须是宿主机真实路径。 因为 `deploy-control` 会通过宿主机 Docker socket 再创建新容器,传给 Docker 的 bind mount 源路径必须是宿主机真实路径。
### `nip.io` 解析失败 ### 本地域名解析失败
检查: 检查:
```bash ```bash
ping 127.0.0.1.nip.io getent hosts alice.localhost
``` ```
如果本地网络屏蔽了 `nip.io`,子域名测试会失败。可以临时换成本机 hosts 或正式域名。 如果浏览器或系统没有把 `<slug>.localhost` 解析到本机,可以临时在 `/etc/hosts` 添加当前测试账号的主机名:
```text
127.0.0.1 alice.localhost
```
旧版文档使用过 `127.0.0.1.nip.io`,但部分网络会把 `nip.io` / `sslip.io` / `lvh.me` 劫持到非本机地址。例如 `127.0.0.1.nip.io` 可能被解析成 `198.18.1.27`这通常是代理、VPN 或 DNS 网关返回的假 IP不是 Beaver 服务地址。遇到这种情况,本机测试优先使用 `localhost` 子域名。
### 服务器上不能用 `.localhost`
`localhost` 是保留域名,浏览器会把它解析到“当前这台客户端机器”。所以:
- 在 Beaver 服务器本机浏览器里打开 `alice.localhost`,访问的是 Beaver 服务器本机。
- 在另一台电脑浏览器里打开 `alice.localhost`,访问的是那台电脑自己。
因此远程访问、局域网访问和正式部署都不能使用 `*.localhost`。请改用真实域名或可被客户端解析到服务器的内部域名,并保证 `router-proxy` 能收到原始 `Host` 头。
### 端口被占用 ### 端口被占用
@ -472,7 +734,7 @@ ping 127.0.0.1.nip.io
ss -ltnp | grep -E '3081|8088|8090|19090' ss -ltnp | grep -E '3081|8088|8090|19090'
``` ```
## 16. 重新部署基础容器 ## 17. 重新部署基础容器
只重建基础四个容器: 只重建基础四个容器: