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) {
`Copy ` +
`${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 ``;
+}
+
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 =
+ `
+ Public key (add this to remote servers' authorized_keys)
+
+
+
+ Copy public key
+ Regenerate…
+
`;
+ } else {
+ area.innerHTML =
+ `No SSH key found in your data folder.
+ Generate SSH key `;
+ }
+}
+
+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 %}
-
+
-
+
Filter
Reset
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 @@
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
Files
-
-
-
-
-
-
- Download folder as ZIP
-
+
+
+
+
+
+
+
+ Upload
+
+
+
+
+
+
+
+ Download folder as ZIP
+
+
+
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 Settings → Credentials 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.
- 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 mode — agent (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.
@@ -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 mode — agent, 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)
Agent Control : Pause / Resume the global kill switch
- Runtime Limits : Max Tool Calls (per run) and Max Autonomous Runs per Hour — stored in the credential store for live override without restart
+ Runtime Limits : Max Tool Calls (per run) and Max Autonomous Runs per Hour - stored in the credential store for live override without restart
Trusted Proxy IPs : Comma-separated IPs for X-Forwarded-For trust (requires restart)
Users Base Folder : Set system:users_base_folder to an absolute path (e.g. /data/users) to enable per-user file storage. Each user's folder at {base}/{username}/ is created automatically.
Audit Log Retention : Set a retention period in days (0 = keep forever); manual clear available
@@ -427,17 +454,17 @@ mcp = FastMCP(
Credentials (Admin)
- A generic AES-256-GCM encrypted key-value store for API keys and other secrets. Keys use a namespace:key convention. Service-specific credentials (CalDAV, CardDAV, Pushover) are managed in their own dedicated tabs — they do not appear here. See the Credential Key Reference for a full list of system keys.
+ A generic AES-256-GCM encrypted key-value store for API keys and other secrets. Keys use a namespace:key convention. Service-specific credentials (CalDAV, CardDAV, Pushover) are managed in their own dedicated tabs - they do not appear here. See the Credential Key Reference for a full list of system keys.
DAV (CalDAV & CardDAV) (Admin)
- Configure CalDAV and CardDAV for the admin user. There is no system-wide fallback — every user configures their own credentials independently via this tab (admin) or the CalDAV / CardDAV tab (regular users).
+ Configure CalDAV and CardDAV for the admin user. There is no system-wide fallback - every user configures their own credentials independently via this tab (admin) or the CalDAV / CardDAV tab (regular users).
- CalDAV : server URL, username, password, and calendar name. Bare hostnames (e.g. mail.example.com) are accepted — https:// is prepended automatically.
+ CalDAV : server URL, username, password, and calendar name. Bare hostnames (e.g. mail.example.com) are accepted - https:// is prepended automatically.
CardDAV : tick Same server as CalDAV to reuse the same credentials, or enter a separate URL, username, and password. The SOGo URL pattern (/SOGo/dav/{user}/Contacts/personal/) is built automatically.
- Allow contact writes : when enabled, agents can create, update, and delete contacts (not just read them). This is per-user — enabling it for your account does not affect other users.
+ Allow contact writes : when enabled, agents can create, update, and delete contacts (not just read them). This is per-user - enabling it for your account does not affect other users.
Test buttons : verify CalDAV and CardDAV connectivity without saving.
@@ -446,7 +473,7 @@ mcp = FastMCP(
Pushover sends push notifications to iOS and Android devices.
- App Token : registered once at pushover.net for this oAI-Web installation. Shared by all users — they cannot see or change it.
+ App Token : registered once at pushover.net for this oAI-Web installation. Shared by all users - they cannot see or change it.
User Key : the admin's personal Pushover user key, shown on your pushover.net dashboard. Each user sets their own User Key in Settings → Pushover .
{% endif %}
@@ -457,7 +484,7 @@ mcp = FastMCP(
Trigger Rules : keyword phrases that, when matched in an incoming email subject/body, dispatch a specific agent and optionally send an auto-reply
- Matching is case-insensitive and order-independent — all tokens in the phrase must appear somewhere in the message
+ Matching is case-insensitive and order-independent - all tokens in the phrase must appear somewhere in the message
Email Accounts
@@ -491,10 +518,10 @@ mcp = FastMCP(
{% if not (current_user and current_user.is_admin) %}
CalDAV / CardDAV
- Configure your personal CalDAV and CardDAV connection. There is no system-wide fallback — if you don't configure it, the tools are unavailable to you.
+ Configure your personal CalDAV and CardDAV connection. There is no system-wide fallback - if you don't configure it, the tools are unavailable to you.
- CalDAV : server URL, username, password, and calendar name. Bare hostnames are accepted — https:// is added automatically.
+ CalDAV : server URL, username, password, and calendar name. Bare hostnames are accepted - https:// is added automatically.
CardDAV : tick Same server as CalDAV to reuse credentials, or enter separate details.
Allow contact writes : when enabled, agents can create, update, and delete contacts.
Test buttons : verify connectivity before saving.
@@ -502,18 +529,18 @@ mcp = FastMCP(
Pushover
- Set your personal User Key to receive push notifications on your Pushover-connected devices. Your User Key is shown on your pushover.net dashboard. The App Token (the shared application credential) is managed by the admin — you only need your own User Key.
+ Set your personal User Key to receive push notifications on your Pushover-connected devices. Your User Key is shown on your pushover.net dashboard. The App Token (the shared application credential) is managed by the admin - you only need your own User Key.
{% endif %}
Webhooks
- Inbound webhooks let external services trigger agents via HTTP — useful for iOS Shortcuts, GitHub actions, Home Assistant automations, or any tool that can send an HTTP request.
+ Inbound webhooks let external services trigger agents via HTTP - useful for iOS Shortcuts, GitHub actions, Home Assistant automations, or any tool that can send an HTTP request.
- Create a webhook : assign a name, description, and target agent. The secret token is shown once at creation — copy it immediately. Use Rotate Token to generate a new one if it is ever compromised.
+ Create a webhook : assign a name, description, and target agent. The secret token is shown once at creation - copy it immediately. Use Rotate Token to generate a new one if it is ever compromised.
Trigger via POST : POST /webhook/{token} with body {"message": "..."}
- Trigger via GET : GET /webhook/{token}?q=your+message — useful for iOS Shortcuts URL actions
+ Trigger via GET : GET /webhook/{token}?q=your+message - useful for iOS Shortcuts URL actions
Enable/disable : toggle a webhook on/off without deleting it
The Outbound Targets section (same tab) manages named URLs that agents can send JSON payloads to via the webhook tool.
@@ -527,11 +554,26 @@ mcp = FastMCP(
Two-Factor Authentication (TOTP) : enable/disable TOTP-based MFA. On setup, a QR code is shown to scan with any authenticator app (e.g. Aegis, Google Authenticator). Once enabled, every login requires a 6-digit code.
Data Folder : shows the path of your auto-provisioned personal folder (set by admin via system:users_base_folder). This folder is where the Files page browses and where agent memory files are stored.
Telegram Bot Token : per-user Telegram bot token (optional). Overrides the global token for your sessions.
+ SSH Key : generate a personal ed25519 SSH key pair stored in your data folder. See below.
+ SSH Key
+
+ The SSH Key section lets you generate an ed25519 key pair directly from the browser. The private key is stored in your data folder ({your_folder}/.ssh/id_ed25519) and never leaves the server. The public key is displayed so you can copy it to any remote server's ~/.ssh/authorized_keys.
+
+
+ Click Generate SSH Key to create a new key pair. The registered email address is used as the key comment.
+ If a key already exists, the button changes to Regenerate - doing so replaces the existing key pair. Any remote servers that trusted the old public key will need to be updated.
+ Use the Copy button to copy the public key to the clipboard, then paste it into ~/.ssh/authorized_keys on the remote server.
+ Agents with bash tool access can use this key to run scp or ssh commands against remote servers that have the public key in their authorized_keys.
+
+
+ The SSH Key section only appears when a data folder is configured for your account. The global ~/.ssh/known_hosts file (mounted from the host system) is still used for host key verification - if you get a host key error, connect manually from the server once to accept it.
+
+
Personality
- Edit SOUL.md (agent identity, values, communication style) and USER.md (owner context: name, location, preferences) directly in the browser. Changes take effect immediately — no restart required.
+ Edit SOUL.md (agent identity, values, communication style) and USER.md (owner context: name, location, preferences) directly in the browser. Changes take effect immediately - no restart required.
Both files are injected into every system prompt in order: SOUL.md → date/time → USER.md → security rules.
@@ -563,15 +605,15 @@ mcp = FastMCP(
API Key (Admin)
Protects the REST API for external programmatic access (scripts, home automations, other services, Swagger).
- The web UI always works without a key — a signed session cookie is set automatically on login.
+ The web UI always works without a key - a signed session cookie is set automatically on login.
The API key is only required for:
External tools and scripts calling /api/* directly
- Swagger UI (/docs ) — click Authorize and enter the key
+ Swagger UI (/docs ) - click Authorize and enter the key
- The raw key is shown once at generation time — copy it to your external tool. Only a SHA-256 hash is stored server-side. Regenerating invalidates the previous key immediately.
+ The raw key is shown once at generation time - copy it to your external tool. Only a SHA-256 hash is stored server-side. Regenerating invalidates the previous key immediately.
Use header X-API-Key: <key> or Authorization: Bearer <key> in external requests.
@@ -606,7 +648,7 @@ mcp = FastMCP(
MFA Management
- Users set up their own TOTP in Settings → Profile → Two-Factor Authentication . As admin, you can clear any user's MFA from the Users page (useful if they lose their authenticator). The Clear MFA button resets their TOTP secret — they must set it up again on next login.
+ Users set up their own TOTP in Settings → Profile → Two-Factor Authentication . As admin, you can clear any user's MFA from the Users page (useful if they lose their authenticator). The Clear MFA button resets their TOTP secret - they must set it up again on next login.
User Filesystem Scoping
@@ -626,7 +668,7 @@ mcp = FastMCP(
Key Description
- system:pausedKill switch — set to "1" to pause all agent activity
+ system:pausedKill switch - set to "1" to pause all agent activity
system:max_tool_callsLive override of MAX_TOOL_CALLS env var
system:max_autonomous_runs_per_hourLive override of MAX_AUTONOMOUS_RUNS_PER_HOUR
system:audit_retention_daysDays to keep audit entries (0 = keep forever)
@@ -641,7 +683,7 @@ mcp = FastMCP(
system:canary_rotated_atTimestamp of last canary rotation (read-only)
system:security_llm_screen_enabledOption 3: LLM content screening enabled
system:security_llm_screen_modelModel for LLM screening (default: google/gemini-flash-1.5)
- system:security_llm_screen_blockOption 3 block mode — block vs flag on UNSAFE verdict
+ system:security_llm_screen_blockOption 3 block mode - block vs flag on UNSAFE verdict
system:security_output_validation_enabledOption 4: output validation for inbox sessions
system:security_truncation_enabledOption 5: content truncation
system:security_max_web_charsMax chars from web fetch (default: 20 000)
@@ -650,7 +692,7 @@ mcp = FastMCP(
system:security_max_subject_charsMax chars of email subject (default: 200)
telegram:bot_tokenGlobal Telegram bot API token
telegram:default_agent_idUUID of agent for unmatched Telegram messages
- pushover_app_tokenPushover App Token — managed via Settings → Pushover , not this tab
+ pushover_app_tokenPushover App Token - managed via Settings → Pushover , not this tab
brain:mcp_key2nd Brain MCP authentication key
system:api_key_hashSHA-256 hash of the external API key (raw key never stored)
system:api_key_created_atTimestamp of last API key generation
@@ -705,8 +747,8 @@ mcp = FastMCP(
Method Path Description
- GET /api/settings/api-keyReturns {configured: bool, created_at} — never returns the raw key
- POST /api/settings/api-keyGenerate a new key — returns {key} once only; invalidates previous key
+ GET /api/settings/api-keyReturns {configured: bool, created_at} - never returns the raw key
+ POST /api/settings/api-keyGenerate a new key - returns {key} once only; invalidates previous key
DELETE /api/settings/api-keyRevoke the current key
@@ -849,6 +891,8 @@ mcp = FastMCP(
DELETE /api/my/filesDelete a file; param: ?path=. Protected names (memory_*, reasoning_*) return 403.
GET /api/my/files/downloadDownload a single file; param: ?path=
GET /api/my/files/download-zipDownload a folder as ZIP; param: ?path=
+ POST /api/my/files/uploadUpload one or more files to the user's folder; param: ?path=, multipart form body
+ GET /api/my/files/viewReturn text file contents (max 512 KB); param: ?path=
GET /api/my/data-folderReturn the user's provisioned data folder path
@@ -864,7 +908,7 @@ mcp = FastMCP(
GET /api/my/themeGet current theme
POST /api/my/themeSet theme {theme_id}
GET /api/my/mfa/statusWhether MFA is enabled for the current user
- POST /api/my/mfa/setup/beginStart MFA setup — returns QR code PNG (base64) and provisioning URI
+ POST /api/my/mfa/setup/beginStart MFA setup - returns QR code PNG (base64) and provisioning URI
POST /api/my/mfa/setup/confirmConfirm setup with a valid TOTP code {code}
DELETE /api/my/mfa/disableDisable MFA for the current user
GET /api/my/caldav/configGet per-user CalDAV & CardDAV config
@@ -876,6 +920,8 @@ mcp = FastMCP(
POST /api/my/pushoverSave personal User Key {user_key}
DELETE /api/my/pushoverRemove personal User Key
GET /api/my/telegram/whitelisted-chatsList Telegram chat IDs whitelisted for the current user
+ GET /api/my/ssh/pubkeyReturn the user's SSH public key (or {exists: false} if not generated yet)
+ POST /api/my/ssh/generateGenerate (or regenerate) an ed25519 SSH key pair in the user's data folder; returns the public key
@@ -886,16 +932,16 @@ mcp = FastMCP(
Method Path Description
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
- Email whitelist — {{ agent_name }} can only send email to addresses explicitly approved here
- Web whitelist (Tier 1) — domains always accessible; subdomains included automatically
- Filesystem sandbox — {{ agent_name }} can only read/write within declared directories (or a user's personal folder)
+ Email whitelist - {{ agent_name }} can only send email to addresses explicitly approved here
+ Web whitelist (Tier 1) - domains always accessible; subdomains included automatically
+ Filesystem sandbox - {{ agent_name }} can only read/write within declared directories (or a user's personal folder)
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
- Enhanced Sanitization — removes known prompt-injection patterns from all external content before it reaches the agent
- 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
- LLM Content Screening — a cheap secondary model screens fetched content for malicious instructions; operates in flag or block mode
- Output Validation — prevents inbox auto-reply loops by blocking outbound emails back to the triggering sender
- Content Truncation — enforces maximum character limits on web fetch, email, and file content to limit the attack surface of large malicious documents
+ Enhanced Sanitization - removes known prompt-injection patterns from all external content before it reaches the agent
+ 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
+ LLM Content Screening - a cheap secondary model screens fetched content for malicious instructions; operates in flag or block mode
+ Output Validation - prevents inbox auto-reply loops by blocking outbound emails back to the triggering sender
+ 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):
- /work pause — temporarily pause the email account's listener
- /work resume — resume the listener
- /work status — show the account's current status
- /work <any message> — pass the message to the handling agent
+ /work pause - temporarily pause the email account's listener
+ /work resume - resume the listener
+ /work status - show the account's current status
+ /work <any message> - pass the message to the handling agent
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:
- Email tool — list, read, mark as read, move, create folders
- Filesystem tool — scoped to the user's data folder (if configured)
- Memory files — memory_<username>.md (persistent notes) and reasoning_<username>.md (append-only decision log) are injected into each run
- Telegram tool (optional) — bound to the account's associated chat ID; reply messages automatically include a /keyword <reply> footer for easy follow-up
+ Email tool - list, read, mark as read, move, create folders
+ Filesystem tool - scoped to the user's data folder (if configured)
+ Memory files - memory_<username>.md (persistent notes) and reasoning_<username>.md (append-only decision log) are injected into each run
+ Telegram tool (optional) - bound to the account's associated chat ID; reply messages automatically include a /keyword <reply> footer for easy follow-up
Pushover tool (optional, admin only)
@@ -1081,8 +1127,8 @@ mcp = FastMCP(
Both Telegram and email inbox use the same trigger-matching algorithm:
- Case-insensitive — URGENT matches urgent
- Order-independent — all tokens in the trigger phrase must appear somewhere in the message, but not necessarily in sequence
+ Case-insensitive - URGENT matches urgent
+ Order-independent - all tokens in the trigger phrase must appear somewhere in the message, but not necessarily in sequence
Example: trigger phrase daily report matches "Send me the report for the daily standup" but also "Daily summary report please"
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 @@
DAV
Pushover
Inbox
- Email Accounts
+ Email
Telegram
Personality
2nd Brain
- MCP Servers
+ MCP
Security
Branding
Webhooks
@@ -36,10 +36,10 @@
API Keys
Personality
Inbox
- Email Accounts
+ Email
CalDAV / CardDAV
Telegram
- MCP Servers
+ MCP
2nd Brain
Pushover
Webhooks
@@ -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.
+
+
+ Allowed extensions
+
+
+
+
+
Save security settings
@@ -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 %}