Updated to version 1.2.6

This commit is contained in:
2026-04-28 09:41:56 +02:00
parent c5a5356a0d
commit c951185cdc
12 changed files with 707 additions and 145 deletions
+164 -2
View File
@@ -849,11 +849,12 @@ async def list_all_runs(
@router.get("/agent-runs/{run_id}")
async def get_agent_run(request: Request, run_id: str):
_require_auth(request)
user = _require_auth(request)
from ..agents.tasks import get_run
run = await get_run(run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
_check_agent_access(await agent_store.get_agent(run["agent_id"]), user)
return run
@@ -1475,6 +1476,49 @@ async def refresh_mcp_server(request: Request, server_id: str):
# ── Security settings ─────────────────────────────────────────────────────────
_UPLOAD_DEFAULT_EXTENSIONS: list[str] = [
# Text / code
"txt", "md", "csv", "json", "xml", "yaml", "yml", "toml", "ini", "conf", "cfg",
"html", "css", "js", "ts", "jsx", "tsx", "py", "sh", "bash", "log", "sql",
"env", "rst", "diff", "patch", "tsv", "nfo", "gitignore", "dockerfile",
"rb", "go", "java", "c", "cpp", "h", "rs", "swift", "kt",
# Images
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "avif", "heic", "heif",
# Documents
"pdf",
]
# Exact filenames (no extension) that are always allowed regardless of extension rules.
# Checked by lowercase basename — not editable via the policy UI.
_UPLOAD_ALLOWED_EXACT_NAMES: frozenset[str] = frozenset([
"known_hosts", "authorized_keys", "config", # SSH
"makefile", "procfile", "dockerfile", # build
".env", ".gitignore", ".htaccess", # config dotfiles
])
_UPLOAD_DEFAULT_MAX_MB = 50
_UPLOAD_DEFAULT_MAX_FILES = 20
async def _get_upload_policy() -> dict:
import json as _json
raw_ext = await credential_store.get("system:upload_allowed_extensions")
raw_mb = await credential_store.get("system:upload_max_file_size_mb")
raw_n = await credential_store.get("system:upload_max_files")
try:
exts = _json.loads(raw_ext) if raw_ext else _UPLOAD_DEFAULT_EXTENSIONS
except Exception:
exts = _UPLOAD_DEFAULT_EXTENSIONS
try:
max_mb = int(raw_mb) if raw_mb else _UPLOAD_DEFAULT_MAX_MB
except (ValueError, TypeError):
max_mb = _UPLOAD_DEFAULT_MAX_MB
try:
max_files = int(raw_n) if raw_n else _UPLOAD_DEFAULT_MAX_FILES
except (ValueError, TypeError):
max_files = _UPLOAD_DEFAULT_MAX_FILES
return {"allowed_extensions": exts, "max_file_size_mb": max_mb, "max_files": max_files}
class SecuritySettingsIn(BaseModel):
sanitize_enhanced: Optional[bool] = None
canary_enabled: Optional[bool] = None
@@ -1487,6 +1531,9 @@ class SecuritySettingsIn(BaseModel):
max_email_chars: Optional[int] = None
max_file_chars: Optional[int] = None
max_subject_chars: Optional[int] = None
upload_allowed_extensions: Optional[list[str]] = None
upload_max_file_size_mb: Optional[int] = None
upload_max_files: Optional[int] = None
@router.get("/settings/security")
@@ -1506,7 +1553,7 @@ async def get_security_settings(request: Request):
sanitize_enhanced, canary_enabled, output_validation_enabled,
truncation_enabled, llm_screen_enabled, llm_screen_block,
max_web_chars, max_email_chars, max_file_chars, max_subject_chars,
llm_screen_model,
llm_screen_model, upload_policy,
) = await asyncio_gather(
_bool("system:security_sanitize_enhanced"),
_bool("system:security_canary_enabled"),
@@ -1519,6 +1566,7 @@ async def get_security_settings(request: Request):
_int("system:security_max_file_chars", 20000),
_int("system:security_max_subject_chars", 200),
credential_store.get("system:security_llm_screen_model"),
_get_upload_policy(),
)
return {
"sanitize_enhanced": sanitize_enhanced,
@@ -1532,6 +1580,7 @@ async def get_security_settings(request: Request):
"llm_screen_enabled": llm_screen_enabled,
"llm_screen_model": llm_screen_model or "google/gemini-flash-1.5",
"llm_screen_block": llm_screen_block,
**upload_policy,
}
@@ -1574,6 +1623,14 @@ async def save_security_settings(request: Request, body: SecuritySettingsIn):
if body.llm_screen_block is not None:
ops.append(credential_store.set("system:security_llm_screen_block", "1" if body.llm_screen_block else "0", "LLM screening block mode (vs flag mode)"))
_invalidate_toggle_cache("system:security_llm_screen_block")
if body.upload_allowed_extensions is not None:
import json as _json
exts = [e.lstrip(".").lower().strip() for e in body.upload_allowed_extensions if e.strip()]
ops.append(credential_store.set("system:upload_allowed_extensions", _json.dumps(exts), "Allowed file extensions for user file uploads"))
if body.upload_max_file_size_mb is not None:
ops.append(credential_store.set("system:upload_max_file_size_mb", str(max(1, body.upload_max_file_size_mb)), "Max file size in MB for user uploads"))
if body.upload_max_files is not None:
ops.append(credential_store.set("system:upload_max_files", str(max(1, body.upload_max_files)), "Max number of files per upload request"))
if ops:
await asyncio_gather(*ops)
@@ -2780,6 +2837,58 @@ async def get_my_data_folder(request: Request):
return {"data_folder": folder or ""}
# ── Per-user SSH key ───────────────────────────────────────────────────────────
@router.get("/my/ssh/pubkey")
async def get_my_ssh_pubkey(request: Request):
user = _require_auth(request)
from ..users import get_user_folder
from pathlib import Path
folder = await get_user_folder(user.id)
if not folder:
return {"exists": False, "pubkey": None, "no_folder": True}
pubkey_path = Path(folder) / ".ssh" / "id_ed25519.pub"
if not pubkey_path.exists():
return {"exists": False, "pubkey": None}
return {"exists": True, "pubkey": pubkey_path.read_text().strip()}
@router.post("/my/ssh/generate")
async def generate_my_ssh_key(request: Request):
import asyncio
from pathlib import Path
user = _require_auth(request)
body = await request.json()
force = bool(body.get("force", False))
from ..users import get_user_folder
folder = await get_user_folder(user.id)
if not folder:
raise HTTPException(status_code=400, detail="No user folder configured. Ask your administrator to set system:users_base_folder.")
ssh_dir = Path(folder) / ".ssh"
key_path = ssh_dir / "id_ed25519"
pubkey_path = ssh_dir / "id_ed25519.pub"
if key_path.exists() and not force:
raise HTTPException(status_code=409, detail="SSH key already exists.")
ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
if force:
key_path.unlink(missing_ok=True)
pubkey_path.unlink(missing_ok=True)
email = getattr(user, "email", "") or getattr(user, "username", "agent")
proc = await asyncio.create_subprocess_exec(
"ssh-keygen", "-t", "ed25519", "-C", email,
"-f", str(key_path), "-N", "",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail=f"ssh-keygen failed: {stderr.decode()}")
key_path.chmod(0o600)
pubkey_path.chmod(0o644)
ssh_dir.chmod(0o700)
return {"pubkey": pubkey_path.read_text().strip()}
# ── Per-user file browser ──────────────────────────────────────────────────────
import io as _io
@@ -2943,6 +3052,59 @@ async def download_my_zip(request: Request, path: str = ""):
)
@router.get("/my/files/upload-policy")
async def get_upload_policy(request: Request):
_require_auth(request)
return await _get_upload_policy()
@router.post("/my/files/upload")
async def upload_my_files(request: Request, path: str = ""):
user = _require_auth(request)
from ..users import get_user_folder
base = await get_user_folder(user.id)
if not base:
raise HTTPException(status_code=404, detail="No files folder configured")
target_dir = _resolve_user_path(base, path)
if not _os.path.isdir(target_dir):
raise HTTPException(status_code=400, detail="Target path is not a directory")
policy = await _get_upload_policy()
allowed_exts = {e.lower() for e in policy["allowed_extensions"]}
max_bytes = policy["max_file_size_mb"] * 1024 * 1024
max_files = policy["max_files"]
form = await request.form()
fields = [f for f in form.values() if hasattr(f, "filename") and f.filename]
if len(fields) > max_files:
raise HTTPException(status_code=400, detail=f"Too many files — max {max_files} per upload")
uploaded = []
rejected = []
for field in fields:
safe_name = _os.path.basename(field.filename)
if not safe_name:
continue
has_ext = "." in safe_name
ext = safe_name.rsplit(".", 1)[-1].lower() if has_ext else ""
name_lower = safe_name.lower()
allowed = (ext in allowed_exts) if has_ext else (name_lower in _UPLOAD_ALLOWED_EXACT_NAMES)
if not allowed:
reason = f"File type .{ext} not allowed" if has_ext else f"Extensionless file '{safe_name}' not permitted"
rejected.append({"name": safe_name, "reason": reason})
continue
content = await field.read()
if len(content) > max_bytes:
rejected.append({"name": safe_name, "reason": f"File exceeds {policy['max_file_size_mb']} MB limit"})
continue
dest = _resolve_user_path(base, _os.path.join(path, safe_name))
with open(dest, "wb") as f:
f.write(content)
uploaded.append(safe_name)
return {"ok": True, "uploaded": uploaded, "rejected": rejected}
@router.delete("/my/files")
async def delete_my_file(request: Request, path: str):
"""Delete a file from the user's data folder.