diff --git a/Dockerfile b/Dockerfile index 002e641..aef5623 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,16 @@ RUN apt-get update \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" \ > /etc/apt/sources.list.d/docker.list \ && apt-get update \ - && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \ + && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin openssh-client \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Install Playwright browser (Chromium) and its system dependencies -RUN playwright install --with-deps chromium +# Install Playwright browser (Chromium) and its system dependencies. +# Skip with: docker build --build-arg NO_BROWSER=1 ... (~350MB smaller, browser tool unavailable) +ARG NO_BROWSER=0 +RUN if [ "$NO_BROWSER" = "0" ]; then playwright install --with-deps chromium; fi COPY server/ ./server/ diff --git a/docker-compose.example.yml b/docker-compose.example.yml index a41a9e5..496d8aa 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -15,7 +15,7 @@ services: retries: 5 aide: - image: gitlab.pm/rune/oai-web:latest # use gitlab.pm/rune/oai-web:latest-no-browser to get a smaller image (172mb vs 572mb) + image: image.gitlab.pm/rune/oai-web:latest ports: - "${PORT:-8080}:8080" environment: diff --git a/server/web/routes.py b/server/web/routes.py index 7f935df..9e834f1 100644 --- a/server/web/routes.py +++ b/server/web/routes.py @@ -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. diff --git a/server/web/static/app.js b/server/web/static/app.js index a424c8a..67c9b34 100644 --- a/server/web/static/app.js +++ b/server/web/static/app.js @@ -277,6 +277,12 @@ function _initPage(url) { ══════════════════════════════════════════════════════════════════════════ */ let _fbPath = ""; +let _uploadPolicy = null; +const _UPLOAD_ALLOWED_EXACT_NAMES = new Set([ + "known_hosts", "authorized_keys", "config", + "makefile", "procfile", "dockerfile", + ".env", ".gitignore", ".htaccess", +]); function _fbFmtSize(bytes) { if (bytes === null || bytes === undefined) return "—"; @@ -332,6 +338,7 @@ async function fileBrowserNavigate(path) { const emptyEl = document.getElementById("file-empty"); const noFolder = document.getElementById("file-no-folder"); const zipBtn = document.getElementById("dl-zip-btn"); + const uploadBtn = document.getElementById("upload-btn"); if (!tbody) return; tbody.innerHTML = 'Loading\u2026'; @@ -347,6 +354,7 @@ async function fileBrowserNavigate(path) { tableWrap.style.display = "none"; noFolder.style.display = ""; zipBtn.style.display = "none"; + if (uploadBtn) uploadBtn.style.display = "none"; document.getElementById("file-breadcrumb").innerHTML = ""; } else { tbody.innerHTML = '' + esc(d.detail || "Error loading files") + ""; @@ -357,6 +365,7 @@ async function fileBrowserNavigate(path) { const data = await r.json(); _fbBuildBreadcrumb(data.path); zipBtn.style.display = ""; + if (uploadBtn) uploadBtn.style.display = ""; if (!data.entries.length) { tableWrap.style.display = "none"; @@ -514,6 +523,78 @@ async function fileBrowserDeleteFile(path, name) { } } +function fileBrowserUploadClick() { + document.getElementById("file-upload-input").click(); +} + +async function fileBrowserUploadFiles(input) { + const files = Array.from(input.files); + input.value = ""; + if (!files.length) return; + + if (!_uploadPolicy) { + const pr = await fetch("/api/my/files/upload-policy").catch(function() { return null; }); + if (pr && pr.ok) _uploadPolicy = await pr.json().catch(function() { return null; }); + } + + const policy = _uploadPolicy || { allowed_extensions: [], max_file_size_mb: 50, max_files: 20 }; + const allowedExts = new Set((policy.allowed_extensions || []).map(function(e) { return e.toLowerCase(); })); + const maxBytes = (policy.max_file_size_mb || 50) * 1024 * 1024; + const maxFiles = policy.max_files || 20; + + if (files.length > maxFiles) { + alert("Too many files — max " + maxFiles + " per upload."); + return; + } + + const clientRejected = []; + const fd = new FormData(); + for (const f of files) { + const hasExt = f.name.includes("."); + const ext = hasExt ? f.name.split(".").pop().toLowerCase() : ""; + const allowed = hasExt + ? (allowedExts.size === 0 || allowedExts.has(ext)) + : _UPLOAD_ALLOWED_EXACT_NAMES.has(f.name.toLowerCase()); + if (!allowed) { + clientRejected.push(f.name + (hasExt ? " (type not allowed)" : " (extensionless file not permitted)")); + continue; + } + if (f.size > maxBytes) { + clientRejected.push(f.name + " (exceeds " + policy.max_file_size_mb + " MB limit)"); + continue; + } + fd.append("files", f); + } + + let uploadedCount = 0; + const allRejected = [...clientRejected]; + if ([...fd.entries()].length > 0) { + try { + const r = await fetch("/api/my/files/upload?path=" + encodeURIComponent(_fbPath), { + method: "POST", + credentials: "same-origin", + body: fd, + }); + const d = await r.json().catch(function() { return {}; }); + if (!r.ok) { alert("Upload failed: " + (d.detail || r.statusText)); return; } + uploadedCount = (d.uploaded || []).length; + for (const rej of (d.rejected || [])) allRejected.push(rej.name + " (" + rej.reason + ")"); + } catch(e) { + alert("Upload failed: " + e.message); + return; + } + } + + if (uploadedCount > 0) { + let msg = "Uploaded " + uploadedCount + " file" + (uploadedCount !== 1 ? "s" : ""); + if (allRejected.length) msg += ". Rejected: " + allRejected.join(", "); + showFlash(msg); + fileBrowserNavigate(_fbPath); + } else if (allRejected.length) { + alert("No files uploaded. Rejected:\n" + allRejected.join("\n")); + } +} + const _FB_TEXT_EXTS = new Set([ "md","txt","json","xml","yaml","yml","csv","html","htm","css","js","ts", "jsx","tsx","py","sh","bash","zsh","log","sql","toml","ini","conf","cfg", @@ -913,15 +994,95 @@ function renderMarkdown(text) { `` + `
${esc(p.s)}
`; } - return _renderInline(p.s); + return _renderBlocks(p.s); }).join(""); } +function _renderBlocks(text) { + const lines = text.split('\n'); + const out = []; + let textBuf = []; + let i = 0; + + const flushText = () => { + while (textBuf.length && !textBuf[0].trim()) textBuf.shift(); + while (textBuf.length && !textBuf[textBuf.length - 1].trim()) textBuf.pop(); + if (textBuf.length) out.push(textBuf.map(_renderInline).join('
')); + textBuf = []; + }; + + while (i < lines.length) { + const line = lines[i]; + + // Headings: # through ###### + const hm = line.match(/^(#{1,6}) (.+)/); + if (hm) { + flushText(); + out.push(`${_renderInline(hm[2])}`); + i++; continue; + } + + // Table: current line has | and next line is a GFM separator (|---|---|) + if (line.includes('|') && i + 1 < lines.length && /^\|[\s\-:|]+\|/.test(lines[i + 1])) { + flushText(); + const rows = []; + while (i < lines.length && lines[i].includes('|')) { rows.push(lines[i]); i++; } + out.push(_renderTable(rows)); + continue; + } + + // Unordered list + if (/^[-*+] /.test(line)) { + flushText(); + const items = []; + while (i < lines.length && /^[-*+] /.test(lines[i])) { + items.push(`
  • ${_renderInline(lines[i].replace(/^[-*+] /, ''))}
  • `); + i++; + } + out.push(``); + continue; + } + + // Ordered list + if (/^\d+\. /.test(line)) { + flushText(); + const items = []; + while (i < lines.length && /^\d+\. /.test(lines[i])) { + items.push(`
  • ${_renderInline(lines[i].replace(/^\d+\. /, ''))}
  • `); + i++; + } + out.push(`
      ${items.join('')}
    `); + continue; + } + + textBuf.push(line); + i++; + } + flushText(); + return out.join(''); +} + +function _renderTable(lines) { + const isSep = l => /^\|[\s\-:|]+\|/.test(l); + const parseRow = l => { + const cells = l.split('|'); + if (cells[0].trim() === '') cells.shift(); + if (cells.length && cells[cells.length - 1].trim() === '') cells.pop(); + return cells.map(c => c.trim()); + }; + const header = parseRow(lines[0]); + const body = lines.slice(1).filter(l => !isSep(l) && l.trim()); + const thead = `${header.map(h => `${_renderInline(h)}`).join('')}`; + const tbody = body.map(l => `${parseRow(l).map(c => `${_renderInline(c)}`).join('')}`).join(''); + return `
    ${thead}${tbody}
    `; +} + function _renderInline(text) { let s = esc(text); - s = s.replace(/\*\*(.+?)\*\*/g, "$1"); + s = s.replace(/<br\s*\/?>/gi, '
    '); + s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1'); + s = s.replace(/\*([^*\n]+)\*/g, '$1'); s = s.replace(/`([^`\n]+)`/g, '$1'); - s = s.replace(/\n/g, "
    "); return s; } @@ -1643,7 +1804,7 @@ function switchSettingsTab(name) { if (name === "inbox") { loadInboxStatus(); } if (name === "emailaccounts") { loadEmailAccounts(); } if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); } - if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); } + if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); initSshKey(); } if (name === "webhooks") { loadWebhooks(); loadWebhookTargets(); } if (name === "caldav") { loadAdminCaldav(); } if (name === "pushover") { loadAdminPushover(); } @@ -1790,7 +1951,7 @@ function switchUserTab(name) { if (name === "caldav") { loadMyCaldavConfig(); } if (name === "telegram") { loadMyTelegramConfig(); loadMyTelegramWhitelist(); loadMyTelegramTriggers(); } if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); } - if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); } + if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); initSshKey(); } if (name === "webhooks") { loadMyWebhooks(); loadMyWebhookTargets(); } if (name === "browser") { loadMyBrowserTrusted(); } if (name === "pushover") { loadMyPushover(); } @@ -2509,6 +2670,76 @@ async function loadDataFolder() { : "Not provisioned — ask your administrator to set system:users_base_folder in Credentials."; } +// ── Per-user SSH key ────────────────────────────────────────────────────────── + +async function initSshKey() { + const area = document.getElementById("ssh-key-area"); + if (!area) return; + try { + const r = await fetch("/api/my/ssh/pubkey"); + const d = await r.json(); + if (d.no_folder) { + area.innerHTML = '

    No data folder configured — contact your administrator.

    '; + return; + } + _renderSshKeyArea(d); + } catch(e) { + area.innerHTML = '

    Failed to load SSH key status.

    '; + } +} + +function _renderSshKeyArea(d) { + const area = document.getElementById("ssh-key-area"); + if (!area) return; + if (d.exists) { + area.innerHTML = + `
    + + +
    +
    + + +
    `; + } else { + area.innerHTML = + `

    No SSH key found in your data folder.

    + `; + } +} + +async function generateSshKey(force) { + if (force && !confirm( + "Regenerate SSH key?\n\nThe existing private key will be deleted. " + + "Any remote servers using it will stop working until you update their authorized_keys." + )) return; + const area = document.getElementById("ssh-key-area"); + if (area) area.innerHTML = '

    Generating…

    '; + try { + const r = await fetch("/api/my/ssh/generate", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({force}), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.detail || "Failed"); + _renderSshKeyArea({exists: true, pubkey: d.pubkey}); + showFlash("SSH key generated successfully"); + } catch(e) { + if (area) _renderSshKeyArea({exists: false}); + showFlash("Error: " + e.message); + } +} + +function copySshPubkey() { + const box = document.getElementById("ssh-pubkey-box"); + if (!box) return; + navigator.clipboard.writeText(box.value) + .then(() => showFlash("Public key copied!")) + .catch(() => {}); +} + // ── Per-user CalDAV ─────────────────────────────────────────────────────────── async function loadMyCaldavConfig() { @@ -3778,12 +4009,19 @@ async function loadSecuritySettings() { if (mdEl && data.llm_screen_model) mdEl.value = data.llm_screen_model; const modeEl = document.getElementById("sec-llm-screen-mode"); if (modeEl) modeEl.value = data.llm_screen_block ? "block" : "flag"; + const extEl = document.getElementById("sec-upload-extensions"); + if (extEl && data.allowed_extensions) extEl.value = data.allowed_extensions.join(", "); + setNum("sec-upload-max-mb", data.max_file_size_mb); + setNum("sec-upload-max-files", data.max_files); + _uploadPolicy = { allowed_extensions: data.allowed_extensions, max_file_size_mb: data.max_file_size_mb, max_files: data.max_files }; } catch { /* ignore */ } form.addEventListener("submit", async e => { e.preventDefault(); const getChk = id => { const el = document.getElementById(id); return el ? el.checked : null; }; const getNum = id => { const el = document.getElementById(id); return el ? parseInt(el.value, 10) : null; }; + const extRaw = (document.getElementById("sec-upload-extensions") || {}).value || ""; + const extList = extRaw.split(/[\s,]+/).map(s => s.replace(/^\./, "").toLowerCase()).filter(Boolean); const body = { sanitize_enhanced: getChk("sec-sanitize-enhanced"), truncation_enabled: getChk("sec-truncation-enabled"), @@ -3796,8 +4034,10 @@ async function loadSecuritySettings() { llm_screen_enabled: getChk("sec-llm-screen-enabled"), llm_screen_model: (() => { const el = document.getElementById("sec-llm-screen-model"); return el ? el.value : null; })(), llm_screen_block: (() => { const el = document.getElementById("sec-llm-screen-mode"); return el ? el.value === "block" : null; })(), + upload_allowed_extensions: extList.length ? extList : null, + upload_max_file_size_mb: getNum("sec-upload-max-mb"), + upload_max_files: getNum("sec-upload-max-files"), }; - // Remove null/NaN values for (const k of Object.keys(body)) { if (body[k] === null || (typeof body[k] === "number" && isNaN(body[k]))) delete body[k]; } @@ -3806,8 +4046,12 @@ async function loadSecuritySettings() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (r.ok) showFlash("Security settings saved ✓"); - else showFlash("Error saving security settings"); + if (r.ok) { + showFlash("Security settings saved ✓"); + _uploadPolicy = null; // bust cache so files page picks up new policy + } else { + showFlash("Error saving security settings"); + } }); } @@ -5465,8 +5709,24 @@ function helpSearch(query) { // Sync TOC link visibility document.querySelectorAll(".toc-list a[href^='#']").forEach(a => { const id = a.getAttribute("href").slice(1); - const section = document.getElementById(id); - const visible = !q || !section || section.style.display !== "none"; + const el = document.getElementById(id); + let visible; + if (!q || !el) { + visible = true; + } else if (el.matches("section[data-section]")) { + visible = el.style.display !== "none"; + } else { + // Heading inside a section: scan from this element to the next sibling + // of the same tag to check if the query appears in that content block. + const tag = el.tagName; + visible = false; + let node = el; + while (node) { + if (node.textContent.toLowerCase().includes(q)) { visible = true; break; } + node = node.nextElementSibling; + if (node && node.tagName === tag) break; + } + } a.closest("li").style.display = visible ? "" : "none"; }); diff --git a/server/web/static/style.css b/server/web/static/style.css index 5d74633..8aa1711 100644 --- a/server/web/static/style.css +++ b/server/web/static/style.css @@ -193,6 +193,25 @@ body { border-bottom-left-radius: 2px; } +/* Markdown headings */ +.message-bubble .md-h { margin: 10px 0 4px; font-weight: 600; line-height: 1.3; } +.message-bubble h1.md-h { font-size: 1.4em; } +.message-bubble h2.md-h { font-size: 1.2em; } +.message-bubble h3.md-h { font-size: 1.05em; } +.message-bubble h4.md-h, .message-bubble h5.md-h, .message-bubble h6.md-h { font-size: 1em; } +.message-bubble .md-h:first-child { margin-top: 0; } + +/* Markdown lists */ +.message-bubble .md-list { margin: 6px 0; padding-left: 22px; } +.message-bubble .md-list li { margin: 2px 0; } + +/* Markdown tables */ +.md-table-wrap { overflow-x: auto; margin: 8px 0; } +.md-table { border-collapse: collapse; width: 100%; font-size: 13px; } +.md-table th, .md-table td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; vertical-align: top; } +.md-table thead th { background: var(--bg3); font-weight: 600; } +.md-table tbody tr:nth-child(even) { background: color-mix(in srgb, var(--bg3) 40%, transparent); } + /* Copy response button (below assistant bubble, visible on hover) */ .copy-response-btn { font-size: 11px; @@ -905,7 +924,21 @@ tr:hover td { background: var(--bg2); } /* Tab bar scrollable */ .tabs { overflow-x: auto; flex-wrap: nowrap; } - /* Filter bar */ + /* Filter bar: inputs fill available width, pairs stack two-per-row */ .filter-bar { gap: 6px; } - .filter-bar .form-input { min-width: 0; } + .filter-bar .form-group { flex: 1 1 calc(50% - 6px); min-width: 120px; } + .filter-bar .form-input { width: 100% !important; min-width: 0; box-sizing: border-box; } + .filter-bar-actions { flex: 1 1 100%; } + + /* Chats page: action buttons wrap below content on narrow screens */ + .chat-row { flex-wrap: wrap !important; } + .chat-row-actions { flex: 1 1 100% !important; padding-left: 27px; } + + /* Agent detail header: name + buttons wrap */ + .agent-detail-header { flex-wrap: wrap !important; gap: 12px; } + .agent-detail-header > div:last-child { flex-shrink: 0; } + + /* Fullscreen prompt editor on mobile */ + .pe-kbd-hint { display: none; } + #pe-textarea { padding: 16px !important; font-size: 14px !important; } } diff --git a/server/web/templates/agent_detail.html b/server/web/templates/agent_detail.html index 9ec2133..f08b12d 100644 --- a/server/web/templates/agent_detail.html +++ b/server/web/templates/agent_detail.html @@ -4,7 +4,7 @@ {% block content %}
    -
    +
    ← Agents @@ -130,7 +130,7 @@
    - Ctrl+S to save · Esc to cancel + Ctrl+S to save · Esc to cancel
    diff --git a/server/web/templates/audit.html b/server/web/templates/audit.html index 8d214e4..42969b9 100644 --- a/server/web/templates/audit.html +++ b/server/web/templates/audit.html @@ -30,7 +30,7 @@
    -
    +
    diff --git a/server/web/templates/base.html b/server/web/templates/base.html index f67e663..02bcb54 100644 --- a/server/web/templates/base.html +++ b/server/web/templates/base.html @@ -21,7 +21,7 @@ logo
    diff --git a/server/web/templates/chats.html b/server/web/templates/chats.html index 0b17f7d..bc982f1 100644 --- a/server/web/templates/chats.html +++ b/server/web/templates/chats.html @@ -123,7 +123,7 @@ function renderChats(data) { ${model}
    -
    +
    Open +
    + + +
    +
    Capability Badges -
  • Files
  • +
  • + Files + +
  • Agents
      @@ -92,7 +97,7 @@ oAI-Web (agent name: {{ agent_name }}) is a secure, self-hosted personal AI agent built on the Claude API with full tool-use support. It runs on your home server and exposes a clean web interface for use inside your local network. The agent can read email, browse the web, manage - calendar events, read and write files, send push notifications, generate images, and more — all via + calendar events, read and write files, send push notifications, generate images, and more - all via a structured tool-use loop with optional confirmation prompts before side-effects.

      @@ -100,7 +105,7 @@
      • An API key for at least one AI provider: Anthropic or OpenRouter
      • Python 3.12+ (or Docker)
      • -
      • PostgreSQL (with asyncpg) — the main application database
      • +
      • PostgreSQL (with asyncpg) - the main application database
      • PostgreSQL + pgvector extension only required if you use the 2nd Brain feature (can be the same server)
      @@ -111,7 +116,7 @@
    • On first boot with zero users, you are redirected to /setup to create the first admin account
    • Open SettingsCredentials and add any additional credentials (CalDAV, email, Pushover, etc.)
    • Add email recipients via Settings → Whitelists → Email Whitelist
    • -
    • Add filesystem directories via Settings → Whitelists → Filesystem Sandbox — the agent cannot touch any path outside these directories
    • +
    • Add filesystem directories via Settings → Whitelists → Filesystem Sandbox - the agent cannot touch any path outside these directories
    • Optionally set system:users_base_folder in Credentials to enable per-user file storage (e.g. /data/users)
    • Optionally configure email accounts and Telegram via their respective Settings tabs
    • @@ -119,7 +124,7 @@

      Key Concepts

      Agent
      -
      A configured AI persona with a model, system prompt, optional schedule, and restricted tool set. Agents run headlessly — no confirmation prompts, results logged in run history.
      +
      A configured AI persona with a model, system prompt, optional schedule, and restricted tool set. Agents run headlessly - no confirmation prompts, results logged in run history.
      Tool
      A capability the AI can invoke: read a file, send an email, fetch a web page, generate an image, etc. Every tool call is logged in the Audit Log.
      Confirmation
      @@ -127,7 +132,7 @@
      Audit Log
      An append-only record of every tool call, its arguments, and outcome. Never auto-deleted unless you configure a retention period.
      Credential Store
      -
      An AES-256-GCM encrypted key-value store in PostgreSQL. All secrets (API keys, passwords) live here — never in the agent's context window.
      +
      An AES-256-GCM encrypted key-value store in PostgreSQL. All secrets (API keys, passwords) live here - never in the agent's context window.
      User Folder
      When system:users_base_folder is set, each user gets a personal folder at {base}/{username}/. Agents and the Files page scope all file access to this folder automatically.
      @@ -143,7 +148,7 @@

      Sending Messages

      Press Enter to send. Use Shift+Enter for a newline within your message. - The Clear History button (✕) in the status bar wipes the in-memory conversation for the current session — the agent starts fresh. + The Clear History button (✕) in the status bar wipes the in-memory conversation for the current session - the agent starts fresh.

      File Attachments

      @@ -151,7 +156,7 @@ The paperclip button (📎) in the input bar opens a file picker. Only shown when the active model supports vision or documents. Supported formats:

        -
      • Images: JPEG, PNG, GIF, WebP, AVIF — shown as thumbnails in the preview strip
      • +
      • Images: JPEG, PNG, GIF, WebP, AVIF - shown as thumbnails in the preview strip
      • PDF: shown as a file chip with the filename in the preview strip

      @@ -169,18 +174,18 @@

      Capability Badges

      Small badges in the status bar show what the active model supports:

        -
      • 🎨 Image Gen — can generate images (use via the image_gen tool in agents)
      • -
      • 👁 Vision — can read images and PDFs; the attachment button is shown
      • -
      • 🔧 Tools — supports tool/function calling
      • -
      • 🌐 Online — has live web access built in
      • +
      • 🎨 Image Gen - can generate images (use via the image_gen tool in agents)
      • +
      • 👁 Vision - can read images and PDFs; the attachment button is shown
      • +
      • 🔧 Tools - supports tool/function calling
      • +
      • 🌐 Online - has live web access built in

      Tool Indicators

      While the agent is working, small badges appear below each message:

        -
      • Pulsing blue — tool is currently running
      • -
      • Solid green — tool completed successfully
      • -
      • Solid red — tool failed or returned an error
      • +
      • Pulsing blue - tool is currently running
      • +
      • Solid green - tool completed successfully
      • +
      • Solid red - tool failed or returned an error

      Confirmation Modal

      @@ -210,16 +215,38 @@

      Downloading

        -
      • Download — downloads an individual file.
      • -
      • ↓ ZIP — downloads an entire folder (and its contents) as a ZIP archive. The Download folder as ZIP button in the header always downloads the current folder.
      • +
      • Download - downloads an individual file.
      • +
      • ↓ ZIP - downloads an entire folder (and its contents) as a ZIP archive. The Download folder as ZIP button in the header always downloads the current folder.
      +

      Uploading Files

      +

      + The Upload button (↑ arrow icon) in the header lets you upload one or more files directly into your current folder. +

      +
        +
      • Click Upload and select one or more files from your device.
      • +
      • Files are uploaded to whichever folder you are currently browsing - navigate to the target folder first.
      • +
      • If a file with the same name already exists it will be overwritten without a prompt, so check the folder contents before uploading.
      • +
      • The file list refreshes automatically once the upload completes.
      • +
      +

      Upload limits

      +

      Uploads are restricted by a policy configurable by your administrator under Settings → Security → File Upload Policy:

      +
        +
      • Allowed types: only common text/code files, images (JPG, PNG, GIF, WebP, SVG, …), and PDF are accepted by default. Files with other extensions are rejected.
      • +
      • Max file size: 50 MB per file (default).
      • +
      • Max files per upload: 20 files at once (default).
      • +
      +

      Both limits are checked in the browser before the upload starts, and enforced again on the server. Rejected files are listed in a flash notification; accepted files in the same batch are still uploaded.

      +

      + The Upload button is only shown when a data folder is configured for your account. If it is missing, ask your administrator to set system:users_base_folder. +

      +

      Deleting Files

      A red Delete button appears next to downloadable files. Clicking it shows a confirmation dialog before the file is permanently removed. Deletion is instant and cannot be undone.

      - Protected files: files whose names start with memory_ or reasoning_ cannot be deleted from the UI. These are agent memory and decision logs maintained by email handling agents — deleting them would disrupt the agent's continuity. + Protected files: files whose names start with memory_ or reasoning_ cannot be deleted from the UI. These are agent memory and decision logs maintained by email handling agents - deleting them would disrupt the agent's continuity.

      No Folder Configured?

      @@ -232,7 +259,7 @@

      Agents

      - Agents are headless AI personas with a fixed system prompt, model, and optional cron schedule. Unlike interactive chat, agents run without confirmation modals — their allowed tools are declared at creation time. Results and token usage are logged per-run in the Agents page. + Agents are headless AI personas with a fixed system prompt, model, and optional cron schedule. Unlike interactive chat, agents run without confirmation modals - their allowed tools are declared at creation time. Results and token usage are logged per-run in the Agents page.

      Email handling agents (created automatically by Email Accounts setup) are hidden from the Agents list and Status tab. They are managed exclusively via Settings → Email Accounts. @@ -241,18 +268,18 @@

      Creating an Agent

      Click New Agent on the Agents page. Required fields:

        -
      • Name — displayed in the UI and logs
      • -
      • Model — any model from a configured provider
      • -
      • Prompt — the agent's task description or system prompt (see Prompt Modes below)
      • +
      • Name - displayed in the UI and logs
      • +
      • Model - any model from a configured provider
      • +
      • Prompt - the agent's task description or system prompt (see Prompt Modes below)

      Optional fields:

        -
      • Description — shown in the agent list for reference
      • -
      • Schedule — cron expression for automatic runs
      • -
      • Allowed Tools — restrict which tools the agent may use
      • -
      • Max Tool Calls — per-run limit (overrides the system default)
      • -
      • Sub-agents — toggle to allow this agent to create child agents
      • -
      • Prompt Mode — controls how the prompt is composed (see below)
      • +
      • Description - shown in the agent list for reference
      • +
      • Schedule - cron expression for automatic runs
      • +
      • Allowed Tools - restrict which tools the agent may use
      • +
      • Max Tool Calls - per-run limit (overrides the system default)
      • +
      • Sub-agents - toggle to allow this agent to create child agents
      • +
      • Prompt Mode - controls how the prompt is composed (see below)

      Scheduling

      @@ -260,10 +287,10 @@
      minute  hour  day-of-month  month  day-of-week

      Examples:

        -
      • 0 8 * * 1-5 — weekdays at 08:00
      • -
      • */15 * * * * — every 15 minutes
      • -
      • 0 9 * * 1 — every Monday at 09:00
      • -
      • 30 18 * * * — every day at 18:30
      • +
      • 0 8 * * 1-5 - weekdays at 08:00
      • +
      • */15 * * * * - every 15 minutes
      • +
      • 0 9 * * 1 - every Monday at 09:00
      • +
      • 30 18 * * * - every day at 18:30

      Use the Enable / Disable toggle to pause a schedule without deleting the agent. @@ -278,12 +305,12 @@

      System only
      The standard system prompt is used as-is; the agent prompt becomes the task message sent to the agent. Useful when you want {{ agent_name }}'s full personality but just need to specify a recurring task.
      Agent only
      -
      The agent prompt fully replaces the system prompt — no SOUL.md, no security rules, no USER.md context. Use with caution. Suitable for specialized agents with a completely different persona.
      +
      The agent prompt fully replaces the system prompt - no SOUL.md, no security rules, no USER.md context. Use with caution. Suitable for specialized agents with a completely different persona.

      Tool Restrictions

      - Leave Allowed Tools blank to give the agent access to all tools. Select specific tools to restrict — only those tool schemas are sent to the model, making it structurally impossible to use undeclared tools. + Leave Allowed Tools blank to give the agent access to all tools. Select specific tools to restrict - only those tool schemas are sent to the model, making it structurally impossible to use undeclared tools.

      MCP server tools appear as a single server-level toggle (e.g. Gitea MCP), which enables all tools from that server. Individual built-in tools are listed separately. @@ -297,7 +324,7 @@

      Image Generation in Agents

      - Agents can generate images using the image_gen tool. Important: the agent model must be a text/tool-use model (e.g. Claude Sonnet), not an image-generation model. The image_gen tool calls the image-gen model internally, saves the result to disk, and returns the file path. The default image-gen model is openrouter:openai/gpt-5-image — override via the system:default_image_gen_model credential. + Agents can generate images using the image_gen tool. Important: the agent model must be a text/tool-use model (e.g. Claude Sonnet), not an image-generation model. The image_gen tool calls the image-gen model internally, saves the result to disk, and returns the file path. The default image-gen model is openrouter:openai/gpt-5-image - override via the system:default_image_gen_model credential.

      Generated images are saved to the agent's user folder. The file path is returned as the tool result so the agent can reference it. @@ -308,7 +335,7 @@

      Monitors

      - The Monitors page lets you watch web pages and RSS feeds for changes, then automatically dispatch an agent or send a Pushover notification when something new appears. Monitors run on a schedule in the background — no manual checking needed. + The Monitors page lets you watch web pages and RSS feeds for changes, then automatically dispatch an agent or send a Pushover notification when something new appears. Monitors run on a schedule in the background - no manual checking needed.

      Page Watchers

      @@ -317,18 +344,18 @@

      Fields when creating a page watcher:

        -
      • Name — displayed in the monitor list
      • -
      • URL — the page to watch
      • -
      • Schedule — cron expression (e.g. 0 * * * * = every hour)
      • -
      • CSS Selector — optional; restricts the hash to a specific element on the page (e.g. #price or .headline). Leave blank to watch the entire page.
      • -
      • Agent — agent to dispatch when a change is detected
      • -
      • Notification modeagent (dispatch the agent), pushover (send a push notification), or both
      • +
      • Name - displayed in the monitor list
      • +
      • URL - the page to watch
      • +
      • Schedule - cron expression (e.g. 0 * * * * = every hour)
      • +
      • CSS Selector - optional; restricts the hash to a specific element on the page (e.g. #price or .headline). Leave blank to watch the entire page.
      • +
      • Agent - agent to dispatch when a change is detected
      • +
      • Notification mode - agent (dispatch the agent), pushover (send a push notification), or both

      The table shows Last checked and Last changed timestamps. Use the Check now button to force an immediate check outside the schedule.

      - Page watchers use plain HTTP (not a real browser). For JavaScript-heavy pages where the interesting content is rendered client-side, the CSS selector approach may not work — the agent's browser tool is better suited for those. + Page watchers use plain HTTP (not a real browser). For JavaScript-heavy pages where the interesting content is rendered client-side, the CSS selector approach may not work - the agent's browser tool is better suited for those.

      RSS Feeds

      @@ -337,12 +364,12 @@

      Fields when creating an RSS monitor:

        -
      • Name — displayed in the monitor list
      • -
      • Feed URL — any RSS or Atom feed URL
      • -
      • Schedule — cron expression (e.g. 0 */4 * * * = every 4 hours)
      • -
      • Agent — agent to dispatch for new items
      • -
      • Max items per run — cap on how many new items trigger the agent in one run (default: 5)
      • -
      • Notification modeagent, pushover, or both
      • +
      • Name - displayed in the monitor list
      • +
      • Feed URL - any RSS or Atom feed URL
      • +
      • Schedule - cron expression (e.g. 0 */4 * * * = every 4 hours)
      • +
      • Agent - agent to dispatch for new items
      • +
      • Max items per run - cap on how many new items trigger the agent in one run (default: 5)
      • +
      • Notification mode - agent, pushover, or both

      Already-seen item IDs are tracked so the same item never triggers twice. The monitor sends ETag / If-Modified-Since headers to avoid downloading unchanged feeds unnecessarily. Use the Fetch now button to force an immediate run. @@ -362,7 +389,7 @@

    • Expose an SSE endpoint at /sse
    • Use SSE transport (not stdio)
    • Be compatible with mcp==1.26.*
    • -
    • If built with Python FastMCP: use uvicorn.run(mcp.sse_app(), host=..., port=...)not mcp.run(host=..., port=...) (the latter ignores host/port in mcp 1.26)
    • +
    • If built with Python FastMCP: use uvicorn.run(mcp.sse_app(), host=..., port=...) - not mcp.run(host=..., port=...) (the latter ignores host/port in mcp 1.26)
    • If connecting from a non-localhost IP (e.g. 192.168.x.x): disable DNS rebinding protection:
      from mcp.server.transport_security import TransportSecuritySettings
       mcp = FastMCP(
      @@ -373,7 +400,7 @@ mcp = FastMCP(
       )
      Without this, the server rejects requests with a 421 Misdirected Request error.
    • -
    • oAI-Web connects per-call (open → use → close), not persistent — the server must handle this gracefully
    • +
    • oAI-Web connects per-call (open → use → close), not persistent - the server must handle this gracefully

    Adding an MCP Server

    @@ -382,10 +409,10 @@ mcp = FastMCP(
  • Click Add Server
  • Enter:
      -
    • Name — display name; also used for tool namespacing (slugified)
    • -
    • URL — full SSE endpoint, e.g. http://192.168.1.72:8812/sse
    • -
    • Transport — select sse
    • -
    • API Key — optional bearer token if the server requires authentication
    • +
    • Name - display name; also used for tool namespacing (slugified)
    • +
    • URL - full SSE endpoint, e.g. http://192.168.1.72:8812/sse
    • +
    • Transport - select sse
    • +
    • API Key - optional bearer token if the server requires authentication
  • Click Save
  • @@ -395,7 +422,7 @@ mcp = FastMCP(

    Tool Namespacing

    A server named Gitea MCP (slugified: gitea_mcp) exposes tools as mcp__gitea_mcp__list_repos, mcp__gitea_mcp__create_issue, etc. - In the agent tool picker, the entire server appears as a single toggle — enabling it grants access to all of its tools. + In the agent tool picker, the entire server appears as a single toggle - enabling it grants access to all of its tools.

    Refreshing Tool Discovery

    @@ -412,7 +439,7 @@ mcp = FastMCP(

    General (Admin)

    @@ -886,16 +932,16 @@ mcp = FastMCP( MethodPathDescription GET/api/webhooksList inbound webhook endpoints (admin) - POST/api/webhooksCreate endpoint — returns token once (admin) + POST/api/webhooksCreate endpoint - returns token once (admin) PUT/api/webhooks/{id}Update name/description/agent/enabled (admin) DELETE/api/webhooks/{id}Delete endpoint (admin) - POST/api/webhooks/{id}/rotateRegenerate token — returns new token once (admin) + POST/api/webhooks/{id}/rotateRegenerate token - returns new token once (admin) GET/api/my/webhooksList current user's webhook endpoints POST/api/my/webhooksCreate personal webhook endpoint PUT/api/my/webhooks/{id}Update personal webhook endpoint DELETE/api/my/webhooks/{id}Delete personal webhook endpoint - GET/webhook/{token}Trigger via GET — param: ?q=message (no auth) - POST/webhook/{token}Trigger via POST — body: {"message": "...", "async": true} (no auth) + GET/webhook/{token}Trigger via GET - param: ?q=message (no auth) + POST/webhook/{token}Trigger via POST - body: {"message": "...", "async": true} (no auth) GET/api/webhook-targetsList outbound webhook targets (admin) POST/api/webhook-targetsCreate outbound target (admin) PUT/api/webhook-targets/{id}Update outbound target (admin) @@ -962,14 +1008,14 @@ mcp = FastMCP(

    Core Principle

    - External input is data, never instructions. Email body text, calendar content, web page content, and file contents are all passed as tool results — they are never injected into the system prompt where they could alter {{ agent_name }}'s instructions. + External input is data, never instructions. Email body text, calendar content, web page content, and file contents are all passed as tool results - they are never injected into the system prompt where they could alter {{ agent_name }}'s instructions.

    Three DB-Managed Whitelists

    Tier 2 web access (any URL) is only available in user-initiated chat sessions, never in autonomous agent runs.

    @@ -980,21 +1026,21 @@ mcp = FastMCP(

    Confirmation Flow

    - In interactive chat, any tool with side effects (send email, write/delete files, send notifications, create/delete calendar events) triggers a confirmation modal. The agent pauses until you approve or deny. Agents running headlessly skip confirmations — their scope is declared at creation time. + In interactive chat, any tool with side effects (send email, write/delete files, send notifications, create/delete calendar events) triggers a confirmation modal. The agent pauses until you approve or deny. Agents running headlessly skip confirmations - their scope is declared at creation time.

    Five Security Options

      -
    1. Enhanced Sanitization — removes known prompt-injection patterns from all external content before it reaches the agent
    2. -
    3. Canary Token — a daily-rotating secret in the system prompt; any tool call argument containing the canary is blocked and triggers a Pushover alert, detecting prompt-injection exfiltration attempts
    4. -
    5. LLM Content Screening — a cheap secondary model screens fetched content for malicious instructions; operates in flag or block mode
    6. -
    7. Output Validation — prevents inbox auto-reply loops by blocking outbound emails back to the triggering sender
    8. -
    9. Content Truncation — enforces maximum character limits on web fetch, email, and file content to limit the attack surface of large malicious documents
    10. +
    11. Enhanced Sanitization - removes known prompt-injection patterns from all external content before it reaches the agent
    12. +
    13. Canary Token - a daily-rotating secret in the system prompt; any tool call argument containing the canary is blocked and triggers a Pushover alert, detecting prompt-injection exfiltration attempts
    14. +
    15. LLM Content Screening - a cheap secondary model screens fetched content for malicious instructions; operates in flag or block mode
    16. +
    17. Output Validation - prevents inbox auto-reply loops by blocking outbound emails back to the triggering sender
    18. +
    19. Content Truncation - enforces maximum character limits on web fetch, email, and file content to limit the attack surface of large malicious documents

    Audit Log

    - Every tool call — arguments, result summary, confirmation status, session ID, task ID — is written to an append-only audit log. Logs are never auto-deleted unless you configure a retention period. View them at Audit Log. + Every tool call - arguments, result summary, confirmation status, session ID, task ID - is written to an append-only audit log. Logs are never auto-deleted unless you configure a retention period. View them at Audit Log.

    Kill Switch

    @@ -1004,7 +1050,7 @@ mcp = FastMCP(

    No Credentials in Agent Context

    - API keys, passwords, and tokens are only accessed by the server-side tool implementations. The agent itself never sees a raw credential — it only receives structured results (e.g. a list of calendar events, a fetched page). + API keys, passwords, and tokens are only accessed by the server-side tool implementations. The agent itself never sees a raw credential - it only receives structured results (e.g. a list of calendar events, a fetched page).

    @@ -1036,16 +1082,16 @@ mcp = FastMCP(

    Built-in sub-commands (e.g. for keyword work):

    Only the Telegram chat ID associated with the email account can use its keyword commands. Other chat IDs are rejected.

    -

    Email Inbox — Trigger Accounts

    +

    Email Inbox - Trigger Accounts

    Trigger accounts use IMAP IDLE for instant push notification. When a new email arrives:

    @@ -1064,15 +1110,15 @@ mcp = FastMCP(
  • Non-whitelisted sender + no trigger → silently dropped (reveals nothing to the sender)
  • -

    Email Inbox — Handling Accounts

    +

    Email Inbox - Handling Accounts

    Handling accounts poll every 60 seconds. A dedicated AI agent reads each new email and decides how to handle it. The agent has access to:

    @@ -1081,8 +1127,8 @@ mcp = FastMCP( Both Telegram and email inbox use the same trigger-matching algorithm:

    diff --git a/server/web/templates/settings.html b/server/web/templates/settings.html index 076994a..ca8b073 100644 --- a/server/web/templates/settings.html +++ b/server/web/templates/settings.html @@ -20,11 +20,11 @@ - + - + @@ -36,10 +36,10 @@ - + - + @@ -1110,6 +1110,33 @@
    + +
    +

    File Upload Policy

    +

    + Controls what users can upload via the Files page. Extensions are checked on both client and server. + Separate extensions with commas or spaces (without dots). Leave unchanged to use the system default + (common text, code, image, and PDF formats). Extensionless files are controlled by a fixed server-side + allowlist (known_hosts, authorized_keys, config, .gitignore, etc.) and cannot be overridden here. +

    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + @@ -1232,6 +1259,15 @@ Loading… +
    +

    SSH Key

    +

    + Generate an ed25519 SSH key pair stored in your data folder. The private key never leaves the server. + Copy the public key to remote servers' ~/.ssh/authorized_keys to allow agents to connect. +

    +
    Loading…
    +
    + {% endif %}