Updated to version 1.2.6

This commit is contained in:
2026-04-28 09:41:56 +02:00
parent c5a5356a0d
commit de47688616
12 changed files with 707 additions and 145 deletions
+5 -3
View File
@@ -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" \ && 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 \ > /etc/apt/sources.list.d/docker.list \
&& apt-get update \ && 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/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browser (Chromium) and its system dependencies # Install Playwright browser (Chromium) and its system dependencies.
RUN playwright install --with-deps chromium # 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/ COPY server/ ./server/
+1 -1
View File
@@ -15,7 +15,7 @@ services:
retries: 5 retries: 5
aide: 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: ports:
- "${PORT:-8080}:8080" - "${PORT:-8080}:8080"
environment: environment:
+164 -2
View File
@@ -849,11 +849,12 @@ async def list_all_runs(
@router.get("/agent-runs/{run_id}") @router.get("/agent-runs/{run_id}")
async def get_agent_run(request: Request, run_id: str): async def get_agent_run(request: Request, run_id: str):
_require_auth(request) user = _require_auth(request)
from ..agents.tasks import get_run from ..agents.tasks import get_run
run = await get_run(run_id) run = await get_run(run_id)
if not run: if not run:
raise HTTPException(status_code=404, detail="Run not found") raise HTTPException(status_code=404, detail="Run not found")
_check_agent_access(await agent_store.get_agent(run["agent_id"]), user)
return run return run
@@ -1475,6 +1476,49 @@ async def refresh_mcp_server(request: Request, server_id: str):
# ── Security settings ───────────────────────────────────────────────────────── # ── 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): class SecuritySettingsIn(BaseModel):
sanitize_enhanced: Optional[bool] = None sanitize_enhanced: Optional[bool] = None
canary_enabled: Optional[bool] = None canary_enabled: Optional[bool] = None
@@ -1487,6 +1531,9 @@ class SecuritySettingsIn(BaseModel):
max_email_chars: Optional[int] = None max_email_chars: Optional[int] = None
max_file_chars: Optional[int] = None max_file_chars: Optional[int] = None
max_subject_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") @router.get("/settings/security")
@@ -1506,7 +1553,7 @@ async def get_security_settings(request: Request):
sanitize_enhanced, canary_enabled, output_validation_enabled, sanitize_enhanced, canary_enabled, output_validation_enabled,
truncation_enabled, llm_screen_enabled, llm_screen_block, truncation_enabled, llm_screen_enabled, llm_screen_block,
max_web_chars, max_email_chars, max_file_chars, max_subject_chars, max_web_chars, max_email_chars, max_file_chars, max_subject_chars,
llm_screen_model, llm_screen_model, upload_policy,
) = await asyncio_gather( ) = await asyncio_gather(
_bool("system:security_sanitize_enhanced"), _bool("system:security_sanitize_enhanced"),
_bool("system:security_canary_enabled"), _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_file_chars", 20000),
_int("system:security_max_subject_chars", 200), _int("system:security_max_subject_chars", 200),
credential_store.get("system:security_llm_screen_model"), credential_store.get("system:security_llm_screen_model"),
_get_upload_policy(),
) )
return { return {
"sanitize_enhanced": sanitize_enhanced, "sanitize_enhanced": sanitize_enhanced,
@@ -1532,6 +1580,7 @@ async def get_security_settings(request: Request):
"llm_screen_enabled": llm_screen_enabled, "llm_screen_enabled": llm_screen_enabled,
"llm_screen_model": llm_screen_model or "google/gemini-flash-1.5", "llm_screen_model": llm_screen_model or "google/gemini-flash-1.5",
"llm_screen_block": llm_screen_block, "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: 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)")) 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") _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: if ops:
await asyncio_gather(*ops) await asyncio_gather(*ops)
@@ -2780,6 +2837,58 @@ async def get_my_data_folder(request: Request):
return {"data_folder": folder or ""} 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 ────────────────────────────────────────────────────── # ── Per-user file browser ──────────────────────────────────────────────────────
import io as _io 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") @router.delete("/my/files")
async def delete_my_file(request: Request, path: str): async def delete_my_file(request: Request, path: str):
"""Delete a file from the user's data folder. """Delete a file from the user's data folder.
+270 -10
View File
@@ -277,6 +277,12 @@ function _initPage(url) {
*/ */
let _fbPath = ""; 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) { function _fbFmtSize(bytes) {
if (bytes === null || bytes === undefined) return "—"; if (bytes === null || bytes === undefined) return "—";
@@ -332,6 +338,7 @@ async function fileBrowserNavigate(path) {
const emptyEl = document.getElementById("file-empty"); const emptyEl = document.getElementById("file-empty");
const noFolder = document.getElementById("file-no-folder"); const noFolder = document.getElementById("file-no-folder");
const zipBtn = document.getElementById("dl-zip-btn"); const zipBtn = document.getElementById("dl-zip-btn");
const uploadBtn = document.getElementById("upload-btn");
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-dim)">Loading\u2026</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-dim)">Loading\u2026</td></tr>';
@@ -347,6 +354,7 @@ async function fileBrowserNavigate(path) {
tableWrap.style.display = "none"; tableWrap.style.display = "none";
noFolder.style.display = ""; noFolder.style.display = "";
zipBtn.style.display = "none"; zipBtn.style.display = "none";
if (uploadBtn) uploadBtn.style.display = "none";
document.getElementById("file-breadcrumb").innerHTML = ""; document.getElementById("file-breadcrumb").innerHTML = "";
} else { } else {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red)">' + esc(d.detail || "Error loading files") + "</td></tr>"; tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red)">' + esc(d.detail || "Error loading files") + "</td></tr>";
@@ -357,6 +365,7 @@ async function fileBrowserNavigate(path) {
const data = await r.json(); const data = await r.json();
_fbBuildBreadcrumb(data.path); _fbBuildBreadcrumb(data.path);
zipBtn.style.display = ""; zipBtn.style.display = "";
if (uploadBtn) uploadBtn.style.display = "";
if (!data.entries.length) { if (!data.entries.length) {
tableWrap.style.display = "none"; 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([ const _FB_TEXT_EXTS = new Set([
"md","txt","json","xml","yaml","yml","csv","html","htm","css","js","ts", "md","txt","json","xml","yaml","yml","csv","html","htm","css","js","ts",
"jsx","tsx","py","sh","bash","zsh","log","sql","toml","ini","conf","cfg", "jsx","tsx","py","sh","bash","zsh","log","sql","toml","ini","conf","cfg",
@@ -913,15 +994,95 @@ function renderMarkdown(text) {
`<button class="copy-code-btn btn btn-ghost btn-small" onclick="copyCode(this)">Copy</button>` + `<button class="copy-code-btn btn btn-ghost btn-small" onclick="copyCode(this)">Copy</button>` +
`</div><pre><code>${esc(p.s)}</code></pre></div>`; `</div><pre><code>${esc(p.s)}</code></pre></div>`;
} }
return _renderInline(p.s); return _renderBlocks(p.s);
}).join(""); }).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('<br>'));
textBuf = [];
};
while (i < lines.length) {
const line = lines[i];
// Headings: # through ######
const hm = line.match(/^(#{1,6}) (.+)/);
if (hm) {
flushText();
out.push(`<h${hm[1].length} class="md-h">${_renderInline(hm[2])}</h${hm[1].length}>`);
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(`<li>${_renderInline(lines[i].replace(/^[-*+] /, ''))}</li>`);
i++;
}
out.push(`<ul class="md-list">${items.join('')}</ul>`);
continue;
}
// Ordered list
if (/^\d+\. /.test(line)) {
flushText();
const items = [];
while (i < lines.length && /^\d+\. /.test(lines[i])) {
items.push(`<li>${_renderInline(lines[i].replace(/^\d+\. /, ''))}</li>`);
i++;
}
out.push(`<ol class="md-list">${items.join('')}</ol>`);
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 = `<thead><tr>${header.map(h => `<th>${_renderInline(h)}</th>`).join('')}</tr></thead>`;
const tbody = body.map(l => `<tr>${parseRow(l).map(c => `<td>${_renderInline(c)}</td>`).join('')}</tr>`).join('');
return `<div class="md-table-wrap"><table class="md-table"><thead>${thead}</thead><tbody>${tbody}</tbody></table></div>`;
}
function _renderInline(text) { function _renderInline(text) {
let s = esc(text); let s = esc(text);
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>"); s = s.replace(/&lt;br\s*\/?&gt;/gi, '<br>');
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
s = s.replace(/`([^`\n]+)`/g, '<code class="inline-code">$1</code>'); s = s.replace(/`([^`\n]+)`/g, '<code class="inline-code">$1</code>');
s = s.replace(/\n/g, "<br>");
return s; return s;
} }
@@ -1643,7 +1804,7 @@ function switchSettingsTab(name) {
if (name === "inbox") { loadInboxStatus(); } if (name === "inbox") { loadInboxStatus(); }
if (name === "emailaccounts") { loadEmailAccounts(); } if (name === "emailaccounts") { loadEmailAccounts(); }
if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); } 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 === "webhooks") { loadWebhooks(); loadWebhookTargets(); }
if (name === "caldav") { loadAdminCaldav(); } if (name === "caldav") { loadAdminCaldav(); }
if (name === "pushover") { loadAdminPushover(); } if (name === "pushover") { loadAdminPushover(); }
@@ -1790,7 +1951,7 @@ function switchUserTab(name) {
if (name === "caldav") { loadMyCaldavConfig(); } if (name === "caldav") { loadMyCaldavConfig(); }
if (name === "telegram") { loadMyTelegramConfig(); loadMyTelegramWhitelist(); loadMyTelegramTriggers(); } if (name === "telegram") { loadMyTelegramConfig(); loadMyTelegramWhitelist(); loadMyTelegramTriggers(); }
if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); } 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 === "webhooks") { loadMyWebhooks(); loadMyWebhookTargets(); }
if (name === "browser") { loadMyBrowserTrusted(); } if (name === "browser") { loadMyBrowserTrusted(); }
if (name === "pushover") { loadMyPushover(); } if (name === "pushover") { loadMyPushover(); }
@@ -2509,6 +2670,76 @@ async function loadDataFolder() {
: "Not provisioned — ask your administrator to set system:users_base_folder in Credentials."; : "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 = '<p style="font-size:13px;color:var(--text-dim)">No data folder configured — contact your administrator.</p>';
return;
}
_renderSshKeyArea(d);
} catch(e) {
area.innerHTML = '<p style="font-size:13px;color:var(--red)">Failed to load SSH key status.</p>';
}
}
function _renderSshKeyArea(d) {
const area = document.getElementById("ssh-key-area");
if (!area) return;
if (d.exists) {
area.innerHTML =
`<div class="form-group">
<label>Public key <span style="font-size:11px;color:var(--text-dim)">(add this to remote servers' authorized_keys)</span></label>
<textarea id="ssh-pubkey-box" class="form-input" readonly rows="3"
style="font-family:var(--mono);font-size:11px;resize:none;line-height:1.5">${esc(d.pubkey)}</textarea>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-ghost btn-small" onclick="copySshPubkey()">Copy public key</button>
<button class="btn btn-ghost btn-small" style="color:var(--text-dim)" onclick="generateSshKey(true)">Regenerate</button>
</div>`;
} else {
area.innerHTML =
`<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">No SSH key found in your data folder.</p>
<button class="btn btn-primary btn-small" onclick="generateSshKey(false)">Generate SSH key</button>`;
}
}
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 = '<p style="font-size:13px;color:var(--text-dim)">Generating…</p>';
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 ─────────────────────────────────────────────────────────── // ── Per-user CalDAV ───────────────────────────────────────────────────────────
async function loadMyCaldavConfig() { async function loadMyCaldavConfig() {
@@ -3778,12 +4009,19 @@ async function loadSecuritySettings() {
if (mdEl && data.llm_screen_model) mdEl.value = data.llm_screen_model; if (mdEl && data.llm_screen_model) mdEl.value = data.llm_screen_model;
const modeEl = document.getElementById("sec-llm-screen-mode"); const modeEl = document.getElementById("sec-llm-screen-mode");
if (modeEl) modeEl.value = data.llm_screen_block ? "block" : "flag"; 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 */ } } catch { /* ignore */ }
form.addEventListener("submit", async e => { form.addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const getChk = id => { const el = document.getElementById(id); return el ? el.checked : null; }; 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 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 = { const body = {
sanitize_enhanced: getChk("sec-sanitize-enhanced"), sanitize_enhanced: getChk("sec-sanitize-enhanced"),
truncation_enabled: getChk("sec-truncation-enabled"), truncation_enabled: getChk("sec-truncation-enabled"),
@@ -3796,8 +4034,10 @@ async function loadSecuritySettings() {
llm_screen_enabled: getChk("sec-llm-screen-enabled"), 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_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; })(), 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)) { for (const k of Object.keys(body)) {
if (body[k] === null || (typeof body[k] === "number" && isNaN(body[k]))) delete body[k]; 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" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (r.ok) showFlash("Security settings saved ✓"); if (r.ok) {
else showFlash("Error saving security settings"); 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 // Sync TOC link visibility
document.querySelectorAll(".toc-list a[href^='#']").forEach(a => { document.querySelectorAll(".toc-list a[href^='#']").forEach(a => {
const id = a.getAttribute("href").slice(1); const id = a.getAttribute("href").slice(1);
const section = document.getElementById(id); const el = document.getElementById(id);
const visible = !q || !section || section.style.display !== "none"; 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"; a.closest("li").style.display = visible ? "" : "none";
}); });
+35 -2
View File
@@ -193,6 +193,25 @@ body {
border-bottom-left-radius: 2px; 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 button (below assistant bubble, visible on hover) */
.copy-response-btn { .copy-response-btn {
font-size: 11px; font-size: 11px;
@@ -905,7 +924,21 @@ tr:hover td { background: var(--bg2); }
/* Tab bar scrollable */ /* Tab bar scrollable */
.tabs { overflow-x: auto; flex-wrap: nowrap; } .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 { 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; }
} }
+2 -2
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="page" id="agent-detail-container"> <div class="page" id="agent-detail-container">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px"> <div class="agent-detail-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
<div> <div>
<a href="/agents" onclick="event.preventDefault();navigateTo('/agents')" <a href="/agents" onclick="event.preventDefault();navigateTo('/agents')"
style="color:var(--text-dim);font-size:13px;text-decoration:none">← Agents</a> style="color:var(--text-dim);font-size:13px;text-decoration:none">← Agents</a>
@@ -130,7 +130,7 @@
<span id="pe-agent-name" style="font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span> <span id="pe-agent-name" style="font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
</div> </div>
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0"> <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<span style="font-size:11px;color:var(--text-dim)">Ctrl+S to save · Esc to cancel</span> <span class="pe-kbd-hint" style="font-size:11px;color:var(--text-dim)">Ctrl+S to save · Esc to cancel</span>
<button class="btn btn-ghost" onclick="closePromptEditor()">Cancel</button> <button class="btn btn-ghost" onclick="closePromptEditor()">Cancel</button>
<button class="btn btn-primary" id="pe-save-btn" onclick="savePromptEditor()">Save</button> <button class="btn btn-primary" id="pe-save-btn" onclick="savePromptEditor()">Save</button>
</div> </div>
+1 -1
View File
@@ -30,7 +30,7 @@
<label>Session ID</label> <label>Session ID</label>
<input type="text" id="filter-session" class="form-input" placeholder="prefix…"> <input type="text" id="filter-session" class="form-input" placeholder="prefix…">
</div> </div>
<div style="display:flex;gap:8px;align-items:flex-end"> <div class="filter-bar-actions" style="display:flex;gap:8px;align-items:flex-end">
<button class="btn btn-primary" id="filter-btn">Filter</button> <button class="btn btn-primary" id="filter-btn">Filter</button>
<button class="btn btn-ghost" id="filter-reset">Reset</button> <button class="btn btn-ghost" id="filter-reset">Reset</button>
</div> </div>
+1 -1
View File
@@ -21,7 +21,7 @@
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img"> <img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
<div class="sidebar-logo-text"> <div class="sidebar-logo-text">
<div class="sidebar-logo-name">{{ brand_name }}</div> <div class="sidebar-logo-name">{{ brand_name }}</div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.5</span></div> <div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.6</span></div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -123,7 +123,7 @@ function renderChats(data) {
${model} ${model}
</div> </div>
</div> </div>
<div style="display:flex;gap:6px;flex-shrink:0"> <div class="chat-row-actions" style="display:flex;gap:6px;flex-shrink:0">
<a class="btn btn-ghost btn-small" href="/?session=${c.id}" <a class="btn btn-ghost btn-small" href="/?session=${c.id}"
data-id="${c.id}" onclick="localStorage.setItem('current_session_id',this.dataset.id)">Open</a> data-id="${c.id}" onclick="localStorage.setItem('current_session_id',this.dataset.id)">Open</a>
<button class="btn btn-ghost btn-small" <button class="btn btn-ghost btn-small"
+24 -10
View File
@@ -7,17 +7,31 @@
<!-- Header --> <!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:16px;flex-wrap:wrap"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:16px;flex-wrap:wrap">
<h1 style="margin:0">Files</h1> <h1 style="margin:0">Files</h1>
<button id="dl-zip-btn" class="btn btn-primary btn-small" onclick="fileBrowserDownloadZip()" <div style="display:flex;gap:8px;flex-wrap:wrap">
style="display:none"> <button id="upload-btn" class="btn btn-ghost btn-small" onclick="fileBrowserUploadClick()"
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none">
width="14" height="14" style="vertical-align:-2px;margin-right:4px"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> width="14" height="14" style="vertical-align:-2px;margin-right:4px">
<polyline points="7 10 12 15 17 10"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<line x1="12" y1="15" x2="12" y2="3"/> <polyline points="17 8 12 3 7 8"/>
</svg> <line x1="12" y1="3" x2="12" y2="15"/>
Download folder as ZIP </svg>
</button> Upload
</button>
<button id="dl-zip-btn" class="btn btn-primary btn-small" onclick="fileBrowserDownloadZip()"
style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
width="14" height="14" style="vertical-align:-2px;margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download folder as ZIP
</button>
</div>
</div> </div>
<input type="file" id="file-upload-input" multiple style="display:none"
onchange="fileBrowserUploadFiles(this)">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div id="file-breadcrumb" <div id="file-breadcrumb"
+154 -108
View File
@@ -23,7 +23,12 @@
<li><a href="#chat-badges">Capability Badges</a></li> <li><a href="#chat-badges">Capability Badges</a></li>
</ul> </ul>
</li> </li>
<li><a href="#files">Files</a></li> <li>
<a href="#files">Files</a>
<ul>
<li><a href="#files-upload">Uploading Files</a></li>
</ul>
</li>
<li> <li>
<a href="#agents">Agents</a> <a href="#agents">Agents</a>
<ul> <ul>
@@ -92,7 +97,7 @@
oAI-Web (agent name: <strong>{{ agent_name }}</strong>) is a secure, self-hosted personal AI agent oAI-Web (agent name: <strong>{{ agent_name }}</strong>) 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 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 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. a structured tool-use loop with optional confirmation prompts before side-effects.
</p> </p>
@@ -100,7 +105,7 @@
<ul> <ul>
<li>An API key for at least one AI provider: <strong>Anthropic</strong> or <strong>OpenRouter</strong></li> <li>An API key for at least one AI provider: <strong>Anthropic</strong> or <strong>OpenRouter</strong></li>
<li>Python 3.12+ (or Docker)</li> <li>Python 3.12+ (or Docker)</li>
<li><strong>PostgreSQL</strong> (with asyncpg) the main application database</li> <li><strong>PostgreSQL</strong> (with asyncpg) - the main application database</li>
<li>PostgreSQL + <strong>pgvector</strong> extension only required if you use the <em>2nd Brain</em> feature (can be the same server)</li> <li>PostgreSQL + <strong>pgvector</strong> extension only required if you use the <em>2nd Brain</em> feature (can be the same server)</li>
</ul> </ul>
@@ -111,7 +116,7 @@
<li>On first boot with zero users, you are redirected to <code>/setup</code> to create the first admin account</li> <li>On first boot with zero users, you are redirected to <code>/setup</code> to create the first admin account</li>
<li>Open <a href="/settings">Settings</a><strong>Credentials</strong> and add any additional credentials (CalDAV, email, Pushover, etc.)</li> <li>Open <a href="/settings">Settings</a><strong>Credentials</strong> and add any additional credentials (CalDAV, email, Pushover, etc.)</li>
<li>Add email recipients via <strong>Settings → Whitelists → Email Whitelist</strong></li> <li>Add email recipients via <strong>Settings → Whitelists → Email Whitelist</strong></li>
<li>Add filesystem directories via <strong>Settings → Whitelists → Filesystem Sandbox</strong> the agent cannot touch any path outside these directories</li> <li>Add filesystem directories via <strong>Settings → Whitelists → Filesystem Sandbox</strong> - the agent cannot touch any path outside these directories</li>
<li>Optionally set <code>system:users_base_folder</code> in Credentials to enable per-user file storage (e.g. <code>/data/users</code>)</li> <li>Optionally set <code>system:users_base_folder</code> in Credentials to enable per-user file storage (e.g. <code>/data/users</code>)</li>
<li>Optionally configure email accounts and Telegram via their respective Settings tabs</li> <li>Optionally configure email accounts and Telegram via their respective Settings tabs</li>
</ol> </ol>
@@ -119,7 +124,7 @@
<h2>Key Concepts</h2> <h2>Key Concepts</h2>
<dl> <dl>
<dt>Agent</dt> <dt>Agent</dt>
<dd>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.</dd> <dd>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.</dd>
<dt>Tool</dt> <dt>Tool</dt>
<dd>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.</dd> <dd>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.</dd>
<dt>Confirmation</dt> <dt>Confirmation</dt>
@@ -127,7 +132,7 @@
<dt>Audit Log</dt> <dt>Audit Log</dt>
<dd>An append-only record of every tool call, its arguments, and outcome. Never auto-deleted unless you configure a retention period.</dd> <dd>An append-only record of every tool call, its arguments, and outcome. Never auto-deleted unless you configure a retention period.</dd>
<dt>Credential Store</dt> <dt>Credential Store</dt>
<dd>An AES-256-GCM encrypted key-value store in PostgreSQL. All secrets (API keys, passwords) live here never in the agent's context window.</dd> <dd>An AES-256-GCM encrypted key-value store in PostgreSQL. All secrets (API keys, passwords) live here - never in the agent's context window.</dd>
<dt>User Folder</dt> <dt>User Folder</dt>
<dd>When <code>system:users_base_folder</code> is set, each user gets a personal folder at <code>{base}/{username}/</code>. Agents and the Files page scope all file access to this folder automatically.</dd> <dd>When <code>system:users_base_folder</code> is set, each user gets a personal folder at <code>{base}/{username}/</code>. Agents and the Files page scope all file access to this folder automatically.</dd>
</dl> </dl>
@@ -143,7 +148,7 @@
<h2>Sending Messages</h2> <h2>Sending Messages</h2>
<p> <p>
Press <kbd>Enter</kbd> to send. Use <kbd>Shift+Enter</kbd> for a newline within your message. Press <kbd>Enter</kbd> to send. Use <kbd>Shift+Enter</kbd> for a newline within your message.
The <strong>Clear History</strong> button (✕) in the status bar wipes the in-memory conversation for the current session the agent starts fresh. The <strong>Clear History</strong> button (✕) in the status bar wipes the in-memory conversation for the current session - the agent starts fresh.
</p> </p>
<h2 id="chat-attachments">File Attachments</h2> <h2 id="chat-attachments">File Attachments</h2>
@@ -151,7 +156,7 @@
The <strong>paperclip button</strong> (📎) in the input bar opens a file picker. Only shown when the active model supports vision or documents. Supported formats: The <strong>paperclip button</strong> (📎) in the input bar opens a file picker. Only shown when the active model supports vision or documents. Supported formats:
</p> </p>
<ul> <ul>
<li><strong>Images</strong>: JPEG, PNG, GIF, WebP, AVIF shown as thumbnails in the preview strip</li> <li><strong>Images</strong>: JPEG, PNG, GIF, WebP, AVIF - shown as thumbnails in the preview strip</li>
<li><strong>PDF</strong>: shown as a file chip with the filename in the preview strip</li> <li><strong>PDF</strong>: shown as a file chip with the filename in the preview strip</li>
</ul> </ul>
<p> <p>
@@ -169,18 +174,18 @@
<h2 id="chat-badges">Capability Badges</h2> <h2 id="chat-badges">Capability Badges</h2>
<p>Small badges in the status bar show what the active model supports:</p> <p>Small badges in the status bar show what the active model supports:</p>
<ul> <ul>
<li>🎨 <strong>Image Gen</strong> can generate images (use via the <code>image_gen</code> tool in agents)</li> <li>🎨 <strong>Image Gen</strong> - can generate images (use via the <code>image_gen</code> tool in agents)</li>
<li>👁 <strong>Vision</strong> can read images and PDFs; the attachment button is shown</li> <li>👁 <strong>Vision</strong> - can read images and PDFs; the attachment button is shown</li>
<li>🔧 <strong>Tools</strong> supports tool/function calling</li> <li>🔧 <strong>Tools</strong> - supports tool/function calling</li>
<li>🌐 <strong>Online</strong> has live web access built in</li> <li>🌐 <strong>Online</strong> - has live web access built in</li>
</ul> </ul>
<h2>Tool Indicators</h2> <h2>Tool Indicators</h2>
<p>While the agent is working, small badges appear below each message:</p> <p>While the agent is working, small badges appear below each message:</p>
<ul> <ul>
<li><span style="color:var(--accent)"></span> <strong>Pulsing blue</strong> tool is currently running</li> <li><span style="color:var(--accent)"></span> <strong>Pulsing blue</strong> - tool is currently running</li>
<li><span style="color:var(--green)"></span> <strong>Solid green</strong> tool completed successfully</li> <li><span style="color:var(--green)"></span> <strong>Solid green</strong> - tool completed successfully</li>
<li><span style="color:var(--red)"></span> <strong>Solid red</strong> tool failed or returned an error</li> <li><span style="color:var(--red)"></span> <strong>Solid red</strong> - tool failed or returned an error</li>
</ul> </ul>
<h2>Confirmation Modal</h2> <h2>Confirmation Modal</h2>
@@ -210,16 +215,38 @@
<h2>Downloading</h2> <h2>Downloading</h2>
<ul> <ul>
<li><strong>Download</strong> downloads an individual file.</li> <li><strong>Download</strong> - downloads an individual file.</li>
<li><strong>↓ ZIP</strong> downloads an entire folder (and its contents) as a ZIP archive. The <strong>Download folder as ZIP</strong> button in the header always downloads the current folder.</li> <li><strong>↓ ZIP</strong> - downloads an entire folder (and its contents) as a ZIP archive. The <strong>Download folder as ZIP</strong> button in the header always downloads the current folder.</li>
</ul> </ul>
<h2 id="files-upload">Uploading Files</h2>
<p>
The <strong>Upload</strong> button (↑ arrow icon) in the header lets you upload one or more files directly into your current folder.
</p>
<ul>
<li>Click <strong>Upload</strong> and select one or more files from your device.</li>
<li>Files are uploaded to whichever folder you are currently browsing - navigate to the target folder first.</li>
<li>If a file with the same name already exists it will be overwritten without a prompt, so check the folder contents before uploading.</li>
<li>The file list refreshes automatically once the upload completes.</li>
</ul>
<h3>Upload limits</h3>
<p>Uploads are restricted by a policy configurable by your administrator under <strong>Settings → Security → File Upload Policy</strong>:</p>
<ul>
<li><strong>Allowed types</strong>: only common text/code files, images (JPG, PNG, GIF, WebP, SVG, …), and PDF are accepted by default. Files with other extensions are rejected.</li>
<li><strong>Max file size</strong>: 50 MB per file (default).</li>
<li><strong>Max files per upload</strong>: 20 files at once (default).</li>
</ul>
<p>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.</p>
<p class="help-note">
The Upload button is only shown when a data folder is configured for your account. If it is missing, ask your administrator to set <code>system:users_base_folder</code>.
</p>
<h2>Deleting Files</h2> <h2>Deleting Files</h2>
<p> <p>
A red <strong>Delete</strong> 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. A red <strong>Delete</strong> 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.
</p> </p>
<p class="help-note"> <p class="help-note">
<strong>Protected files</strong>: files whose names start with <code>memory_</code> or <code>reasoning_</code> 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. <strong>Protected files</strong>: files whose names start with <code>memory_</code> or <code>reasoning_</code> 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.
</p> </p>
<h2>No Folder Configured?</h2> <h2>No Folder Configured?</h2>
@@ -232,7 +259,7 @@
<section id="agents" data-section> <section id="agents" data-section>
<h1>Agents</h1> <h1>Agents</h1>
<p> <p>
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 <a href="/agents">Agents</a> 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 <a href="/agents">Agents</a> page.
</p> </p>
<p class="help-note"> <p class="help-note">
Email handling agents (created automatically by Email Accounts setup) are hidden from the Agents list and Status tab. They are managed exclusively via <strong>Settings → Email Accounts</strong>. Email handling agents (created automatically by Email Accounts setup) are hidden from the Agents list and Status tab. They are managed exclusively via <strong>Settings → Email Accounts</strong>.
@@ -241,18 +268,18 @@
<h2 id="agents-creating">Creating an Agent</h2> <h2 id="agents-creating">Creating an Agent</h2>
<p>Click <strong>New Agent</strong> on the Agents page. Required fields:</p> <p>Click <strong>New Agent</strong> on the Agents page. Required fields:</p>
<ul> <ul>
<li><strong>Name</strong> displayed in the UI and logs</li> <li><strong>Name</strong> - displayed in the UI and logs</li>
<li><strong>Model</strong> any model from a configured provider</li> <li><strong>Model</strong> - any model from a configured provider</li>
<li><strong>Prompt</strong> the agent's task description or system prompt (see Prompt Modes below)</li> <li><strong>Prompt</strong> - the agent's task description or system prompt (see Prompt Modes below)</li>
</ul> </ul>
<p>Optional fields:</p> <p>Optional fields:</p>
<ul> <ul>
<li><strong>Description</strong> shown in the agent list for reference</li> <li><strong>Description</strong> - shown in the agent list for reference</li>
<li><strong>Schedule</strong> cron expression for automatic runs</li> <li><strong>Schedule</strong> - cron expression for automatic runs</li>
<li><strong>Allowed Tools</strong> restrict which tools the agent may use</li> <li><strong>Allowed Tools</strong> - restrict which tools the agent may use</li>
<li><strong>Max Tool Calls</strong> per-run limit (overrides the system default)</li> <li><strong>Max Tool Calls</strong> - per-run limit (overrides the system default)</li>
<li><strong>Sub-agents</strong> toggle to allow this agent to create child agents</li> <li><strong>Sub-agents</strong> - toggle to allow this agent to create child agents</li>
<li><strong>Prompt Mode</strong> controls how the prompt is composed (see below)</li> <li><strong>Prompt Mode</strong> - controls how the prompt is composed (see below)</li>
</ul> </ul>
<h2 id="agents-schedule">Scheduling</h2> <h2 id="agents-schedule">Scheduling</h2>
@@ -260,10 +287,10 @@
<pre>minute hour day-of-month month day-of-week</pre> <pre>minute hour day-of-month month day-of-week</pre>
<p>Examples:</p> <p>Examples:</p>
<ul> <ul>
<li><code>0 8 * * 1-5</code> weekdays at 08:00</li> <li><code>0 8 * * 1-5</code> - weekdays at 08:00</li>
<li><code>*/15 * * * *</code> every 15 minutes</li> <li><code>*/15 * * * *</code> - every 15 minutes</li>
<li><code>0 9 * * 1</code> every Monday at 09:00</li> <li><code>0 9 * * 1</code> - every Monday at 09:00</li>
<li><code>30 18 * * *</code> every day at 18:30</li> <li><code>30 18 * * *</code> - every day at 18:30</li>
</ul> </ul>
<p> <p>
Use the <strong>Enable / Disable</strong> toggle to pause a schedule without deleting the agent. Use the <strong>Enable / Disable</strong> toggle to pause a schedule without deleting the agent.
@@ -278,12 +305,12 @@
<dt>System only</dt> <dt>System only</dt>
<dd>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.</dd> <dd>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.</dd>
<dt>Agent only</dt> <dt>Agent only</dt>
<dd>The agent prompt <em>fully replaces</em> 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.</dd> <dd>The agent prompt <em>fully replaces</em> 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.</dd>
</dl> </dl>
<h2 id="agents-tools">Tool Restrictions</h2> <h2 id="agents-tools">Tool Restrictions</h2>
<p> <p>
Leave <strong>Allowed Tools</strong> 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 <strong>Allowed Tools</strong> 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.
</p> </p>
<p> <p>
MCP server tools appear as a single server-level toggle (e.g. <code>Gitea MCP</code>), which enables all tools from that server. Individual built-in tools are listed separately. MCP server tools appear as a single server-level toggle (e.g. <code>Gitea MCP</code>), which enables all tools from that server. Individual built-in tools are listed separately.
@@ -297,7 +324,7 @@
<h2>Image Generation in Agents</h2> <h2>Image Generation in Agents</h2>
<p> <p>
Agents can generate images using the <code>image_gen</code> tool. Important: the <strong>agent model must be a text/tool-use model</strong> (e.g. Claude Sonnet), not an image-generation model. The <code>image_gen</code> tool calls the image-gen model internally, saves the result to disk, and returns the file path. The default image-gen model is <code>openrouter:openai/gpt-5-image</code> override via the <code>system:default_image_gen_model</code> credential. Agents can generate images using the <code>image_gen</code> tool. Important: the <strong>agent model must be a text/tool-use model</strong> (e.g. Claude Sonnet), not an image-generation model. The <code>image_gen</code> tool calls the image-gen model internally, saves the result to disk, and returns the file path. The default image-gen model is <code>openrouter:openai/gpt-5-image</code> - override via the <code>system:default_image_gen_model</code> credential.
</p> </p>
<p> <p>
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. 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 @@
<section id="monitors" data-section> <section id="monitors" data-section>
<h1>Monitors</h1> <h1>Monitors</h1>
<p> <p>
The <a href="/monitors">Monitors</a> 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 <a href="/monitors">Monitors</a> 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.
</p> </p>
<h2 id="monitors-pages">Page Watchers</h2> <h2 id="monitors-pages">Page Watchers</h2>
@@ -317,18 +344,18 @@
</p> </p>
<p>Fields when creating a page watcher:</p> <p>Fields when creating a page watcher:</p>
<ul> <ul>
<li><strong>Name</strong> displayed in the monitor list</li> <li><strong>Name</strong> - displayed in the monitor list</li>
<li><strong>URL</strong> the page to watch</li> <li><strong>URL</strong> - the page to watch</li>
<li><strong>Schedule</strong> cron expression (e.g. <code>0 * * * *</code> = every hour)</li> <li><strong>Schedule</strong> - cron expression (e.g. <code>0 * * * *</code> = every hour)</li>
<li><strong>CSS Selector</strong> optional; restricts the hash to a specific element on the page (e.g. <code>#price</code> or <code>.headline</code>). Leave blank to watch the entire page.</li> <li><strong>CSS Selector</strong> - optional; restricts the hash to a specific element on the page (e.g. <code>#price</code> or <code>.headline</code>). Leave blank to watch the entire page.</li>
<li><strong>Agent</strong> agent to dispatch when a change is detected</li> <li><strong>Agent</strong> - agent to dispatch when a change is detected</li>
<li><strong>Notification mode</strong> <code>agent</code> (dispatch the agent), <code>pushover</code> (send a push notification), or <code>both</code></li> <li><strong>Notification mode</strong> - <code>agent</code> (dispatch the agent), <code>pushover</code> (send a push notification), or <code>both</code></li>
</ul> </ul>
<p> <p>
The table shows <strong>Last checked</strong> and <strong>Last changed</strong> timestamps. Use the <strong>Check now</strong> button to force an immediate check outside the schedule. The table shows <strong>Last checked</strong> and <strong>Last changed</strong> timestamps. Use the <strong>Check now</strong> button to force an immediate check outside the schedule.
</p> </p>
<p class="help-note"> <p class="help-note">
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.
</p> </p>
<h2 id="monitors-rss">RSS Feeds</h2> <h2 id="monitors-rss">RSS Feeds</h2>
@@ -337,12 +364,12 @@
</p> </p>
<p>Fields when creating an RSS monitor:</p> <p>Fields when creating an RSS monitor:</p>
<ul> <ul>
<li><strong>Name</strong> displayed in the monitor list</li> <li><strong>Name</strong> - displayed in the monitor list</li>
<li><strong>Feed URL</strong> any RSS or Atom feed URL</li> <li><strong>Feed URL</strong> - any RSS or Atom feed URL</li>
<li><strong>Schedule</strong> cron expression (e.g. <code>0 */4 * * *</code> = every 4 hours)</li> <li><strong>Schedule</strong> - cron expression (e.g. <code>0 */4 * * *</code> = every 4 hours)</li>
<li><strong>Agent</strong> agent to dispatch for new items</li> <li><strong>Agent</strong> - agent to dispatch for new items</li>
<li><strong>Max items per run</strong> cap on how many new items trigger the agent in one run (default: 5)</li> <li><strong>Max items per run</strong> - cap on how many new items trigger the agent in one run (default: 5)</li>
<li><strong>Notification mode</strong> <code>agent</code>, <code>pushover</code>, or <code>both</code></li> <li><strong>Notification mode</strong> - <code>agent</code>, <code>pushover</code>, or <code>both</code></li>
</ul> </ul>
<p> <p>
Already-seen item IDs are tracked so the same item never triggers twice. The monitor sends <code>ETag</code> / <code>If-Modified-Since</code> headers to avoid downloading unchanged feeds unnecessarily. Use the <strong>Fetch now</strong> button to force an immediate run. Already-seen item IDs are tracked so the same item never triggers twice. The monitor sends <code>ETag</code> / <code>If-Modified-Since</code> headers to avoid downloading unchanged feeds unnecessarily. Use the <strong>Fetch now</strong> button to force an immediate run.
@@ -362,7 +389,7 @@
<li>Expose an SSE endpoint at <code>/sse</code></li> <li>Expose an SSE endpoint at <code>/sse</code></li>
<li>Use <strong>SSE transport</strong> (not stdio)</li> <li>Use <strong>SSE transport</strong> (not stdio)</li>
<li>Be compatible with <code>mcp==1.26.*</code></li> <li>Be compatible with <code>mcp==1.26.*</code></li>
<li>If built with Python FastMCP: use <code>uvicorn.run(mcp.sse_app(), host=..., port=...)</code> <strong>not</strong> <code>mcp.run(host=..., port=...)</code> (the latter ignores <code>host</code>/<code>port</code> in mcp 1.26)</li> <li>If built with Python FastMCP: use <code>uvicorn.run(mcp.sse_app(), host=..., port=...)</code> - <strong>not</strong> <code>mcp.run(host=..., port=...)</code> (the latter ignores <code>host</code>/<code>port</code> in mcp 1.26)</li>
<li>If connecting from a non-localhost IP (e.g. <code>192.168.x.x</code>): disable DNS rebinding protection: <li>If connecting from a non-localhost IP (e.g. <code>192.168.x.x</code>): disable DNS rebinding protection:
<pre>from mcp.server.transport_security import TransportSecuritySettings <pre>from mcp.server.transport_security import TransportSecuritySettings
mcp = FastMCP( mcp = FastMCP(
@@ -373,7 +400,7 @@ mcp = FastMCP(
)</pre> )</pre>
Without this, the server rejects requests with a <code>421 Misdirected Request</code> error. Without this, the server rejects requests with a <code>421 Misdirected Request</code> error.
</li> </li>
<li>oAI-Web connects per-call (open → use → close), <em>not</em> persistent the server must handle this gracefully</li> <li>oAI-Web connects per-call (open → use → close), <em>not</em> persistent - the server must handle this gracefully</li>
</ul> </ul>
<h2>Adding an MCP Server</h2> <h2>Adding an MCP Server</h2>
@@ -382,10 +409,10 @@ mcp = FastMCP(
<li>Click <strong>Add Server</strong></li> <li>Click <strong>Add Server</strong></li>
<li>Enter: <li>Enter:
<ul> <ul>
<li><strong>Name</strong> display name; also used for tool namespacing (slugified)</li> <li><strong>Name</strong> - display name; also used for tool namespacing (slugified)</li>
<li><strong>URL</strong> full SSE endpoint, e.g. <code>http://192.168.1.72:8812/sse</code></li> <li><strong>URL</strong> - full SSE endpoint, e.g. <code>http://192.168.1.72:8812/sse</code></li>
<li><strong>Transport</strong> select <code>sse</code></li> <li><strong>Transport</strong> - select <code>sse</code></li>
<li><strong>API Key</strong> optional bearer token if the server requires authentication</li> <li><strong>API Key</strong> - optional bearer token if the server requires authentication</li>
</ul> </ul>
</li> </li>
<li>Click <strong>Save</strong></li> <li>Click <strong>Save</strong></li>
@@ -395,7 +422,7 @@ mcp = FastMCP(
<h2>Tool Namespacing</h2> <h2>Tool Namespacing</h2>
<p> <p>
A server named <code>Gitea MCP</code> (slugified: <code>gitea_mcp</code>) exposes tools as <code>mcp__gitea_mcp__list_repos</code>, <code>mcp__gitea_mcp__create_issue</code>, etc. A server named <code>Gitea MCP</code> (slugified: <code>gitea_mcp</code>) exposes tools as <code>mcp__gitea_mcp__list_repos</code>, <code>mcp__gitea_mcp__create_issue</code>, 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.
</p> </p>
<h2>Refreshing Tool Discovery</h2> <h2>Refreshing Tool Discovery</h2>
@@ -412,7 +439,7 @@ mcp = FastMCP(
<h2 id="settings-general">General <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2> <h2 id="settings-general">General <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
<ul> <ul>
<li><strong>Agent Control</strong>: Pause / Resume the global kill switch</li> <li><strong>Agent Control</strong>: Pause / Resume the global kill switch</li>
<li><strong>Runtime Limits</strong>: Max Tool Calls (per run) and Max Autonomous Runs per Hour stored in the credential store for live override without restart</li> <li><strong>Runtime Limits</strong>: Max Tool Calls (per run) and Max Autonomous Runs per Hour - stored in the credential store for live override without restart</li>
<li><strong>Trusted Proxy IPs</strong>: Comma-separated IPs for <code>X-Forwarded-For</code> trust (requires restart)</li> <li><strong>Trusted Proxy IPs</strong>: Comma-separated IPs for <code>X-Forwarded-For</code> trust (requires restart)</li>
<li><strong>Users Base Folder</strong>: Set <code>system:users_base_folder</code> to an absolute path (e.g. <code>/data/users</code>) to enable per-user file storage. Each user's folder at <code>{base}/{username}/</code> is created automatically.</li> <li><strong>Users Base Folder</strong>: Set <code>system:users_base_folder</code> to an absolute path (e.g. <code>/data/users</code>) to enable per-user file storage. Each user's folder at <code>{base}/{username}/</code> is created automatically.</li>
<li><strong>Audit Log Retention</strong>: Set a retention period in days (0 = keep forever); manual clear available</li> <li><strong>Audit Log Retention</strong>: Set a retention period in days (0 = keep forever); manual clear available</li>
@@ -427,17 +454,17 @@ mcp = FastMCP(
<h2 id="settings-credentials">Credentials <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2> <h2 id="settings-credentials">Credentials <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
<p> <p>
A generic AES-256-GCM encrypted key-value store for API keys and other secrets. Keys use a <code>namespace:key</code> convention. Service-specific credentials (CalDAV, CardDAV, Pushover) are managed in their own dedicated tabs they do not appear here. See the <a href="#credentials">Credential Key Reference</a> 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 <code>namespace:key</code> convention. Service-specific credentials (CalDAV, CardDAV, Pushover) are managed in their own dedicated tabs - they do not appear here. See the <a href="#credentials">Credential Key Reference</a> for a full list of system keys.
</p> </p>
<h2 id="settings-dav">DAV (CalDAV &amp; CardDAV) <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2> <h2 id="settings-dav">DAV (CalDAV &amp; CardDAV) <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
<p> <p>
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 <strong>CalDAV / CardDAV</strong> 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 <strong>CalDAV / CardDAV</strong> tab (regular users).
</p> </p>
<ul> <ul>
<li><strong>CalDAV</strong>: server URL, username, password, and calendar name. Bare hostnames (e.g. <code>mail.example.com</code>) are accepted <code>https://</code> is prepended automatically.</li> <li><strong>CalDAV</strong>: server URL, username, password, and calendar name. Bare hostnames (e.g. <code>mail.example.com</code>) are accepted - <code>https://</code> is prepended automatically.</li>
<li><strong>CardDAV</strong>: tick <em>Same server as CalDAV</em> to reuse the same credentials, or enter a separate URL, username, and password. The SOGo URL pattern (<code>/SOGo/dav/{user}/Contacts/personal/</code>) is built automatically.</li> <li><strong>CardDAV</strong>: tick <em>Same server as CalDAV</em> to reuse the same credentials, or enter a separate URL, username, and password. The SOGo URL pattern (<code>/SOGo/dav/{user}/Contacts/personal/</code>) is built automatically.</li>
<li><strong>Allow contact writes</strong>: 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.</li> <li><strong>Allow contact writes</strong>: 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.</li>
<li><strong>Test buttons</strong>: verify CalDAV and CardDAV connectivity without saving.</li> <li><strong>Test buttons</strong>: verify CalDAV and CardDAV connectivity without saving.</li>
</ul> </ul>
@@ -446,7 +473,7 @@ mcp = FastMCP(
Pushover sends push notifications to iOS and Android devices. Pushover sends push notifications to iOS and Android devices.
</p> </p>
<ul> <ul>
<li><strong>App Token</strong>: registered once at <a href="https://pushover.net" target="_blank">pushover.net</a> for this oAI-Web installation. Shared by all users they cannot see or change it.</li> <li><strong>App Token</strong>: registered once at <a href="https://pushover.net" target="_blank">pushover.net</a> for this oAI-Web installation. Shared by all users - they cannot see or change it.</li>
<li><strong>User Key</strong>: the admin's personal Pushover user key, shown on your pushover.net dashboard. Each user sets their own User Key in <strong>Settings → Pushover</strong>.</li> <li><strong>User Key</strong>: the admin's personal Pushover user key, shown on your pushover.net dashboard. Each user sets their own User Key in <strong>Settings → Pushover</strong>.</li>
</ul> </ul>
{% endif %} {% endif %}
@@ -457,7 +484,7 @@ mcp = FastMCP(
</p> </p>
<ul> <ul>
<li><strong>Trigger Rules</strong>: keyword phrases that, when matched in an incoming email subject/body, dispatch a specific agent and optionally send an auto-reply</li> <li><strong>Trigger Rules</strong>: keyword phrases that, when matched in an incoming email subject/body, dispatch a specific agent and optionally send an auto-reply</li>
<li>Matching is case-insensitive and order-independent all tokens in the phrase must appear somewhere in the message</li> <li>Matching is case-insensitive and order-independent - all tokens in the phrase must appear somewhere in the message</li>
</ul> </ul>
<h2 id="settings-emailaccounts">Email Accounts</h2> <h2 id="settings-emailaccounts">Email Accounts</h2>
@@ -491,10 +518,10 @@ mcp = FastMCP(
{% if not (current_user and current_user.is_admin) %} {% if not (current_user and current_user.is_admin) %}
<h2 id="settings-caldav">CalDAV / CardDAV</h2> <h2 id="settings-caldav">CalDAV / CardDAV</h2>
<p> <p>
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.
</p> </p>
<ul> <ul>
<li><strong>CalDAV</strong>: server URL, username, password, and calendar name. Bare hostnames are accepted <code>https://</code> is added automatically.</li> <li><strong>CalDAV</strong>: server URL, username, password, and calendar name. Bare hostnames are accepted - <code>https://</code> is added automatically.</li>
<li><strong>CardDAV</strong>: tick <em>Same server as CalDAV</em> to reuse credentials, or enter separate details.</li> <li><strong>CardDAV</strong>: tick <em>Same server as CalDAV</em> to reuse credentials, or enter separate details.</li>
<li><strong>Allow contact writes</strong>: when enabled, agents can create, update, and delete contacts.</li> <li><strong>Allow contact writes</strong>: when enabled, agents can create, update, and delete contacts.</li>
<li><strong>Test buttons</strong>: verify connectivity before saving.</li> <li><strong>Test buttons</strong>: verify connectivity before saving.</li>
@@ -502,18 +529,18 @@ mcp = FastMCP(
<h2 id="settings-pushover">Pushover</h2> <h2 id="settings-pushover">Pushover</h2>
<p> <p>
Set your personal <strong>User Key</strong> to receive push notifications on your Pushover-connected devices. Your User Key is shown on your <a href="https://pushover.net" target="_blank">pushover.net</a> dashboard. The App Token (the shared application credential) is managed by the admin you only need your own User Key. Set your personal <strong>User Key</strong> to receive push notifications on your Pushover-connected devices. Your User Key is shown on your <a href="https://pushover.net" target="_blank">pushover.net</a> dashboard. The App Token (the shared application credential) is managed by the admin - you only need your own User Key.
</p> </p>
{% endif %} {% endif %}
<h2 id="settings-webhooks">Webhooks</h2> <h2 id="settings-webhooks">Webhooks</h2>
<p> <p>
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.
</p> </p>
<ul> <ul>
<li><strong>Create a webhook</strong>: assign a name, description, and target agent. The secret token is shown <strong>once</strong> at creation copy it immediately. Use <em>Rotate Token</em> to generate a new one if it is ever compromised.</li> <li><strong>Create a webhook</strong>: assign a name, description, and target agent. The secret token is shown <strong>once</strong> at creation - copy it immediately. Use <em>Rotate Token</em> to generate a new one if it is ever compromised.</li>
<li><strong>Trigger via POST</strong>: <code>POST /webhook/{token}</code> with body <code>{"message": "..."}</code></li> <li><strong>Trigger via POST</strong>: <code>POST /webhook/{token}</code> with body <code>{"message": "..."}</code></li>
<li><strong>Trigger via GET</strong>: <code>GET /webhook/{token}?q=your+message</code> useful for iOS Shortcuts URL actions</li> <li><strong>Trigger via GET</strong>: <code>GET /webhook/{token}?q=your+message</code> - useful for iOS Shortcuts URL actions</li>
<li><strong>Enable/disable</strong>: toggle a webhook on/off without deleting it</li> <li><strong>Enable/disable</strong>: toggle a webhook on/off without deleting it</li>
</ul> </ul>
<p>The <strong>Outbound Targets</strong> section (same tab) manages named URLs that agents can send JSON payloads to via the <code>webhook</code> tool.</p> <p>The <strong>Outbound Targets</strong> section (same tab) manages named URLs that agents can send JSON payloads to via the <code>webhook</code> tool.</p>
@@ -527,11 +554,26 @@ mcp = FastMCP(
<li><strong>Two-Factor Authentication (TOTP)</strong>: 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.</li> <li><strong>Two-Factor Authentication (TOTP)</strong>: 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.</li>
<li><strong>Data Folder</strong>: shows the path of your auto-provisioned personal folder (set by admin via <code>system:users_base_folder</code>). This folder is where the Files page browses and where agent memory files are stored.</li> <li><strong>Data Folder</strong>: shows the path of your auto-provisioned personal folder (set by admin via <code>system:users_base_folder</code>). This folder is where the Files page browses and where agent memory files are stored.</li>
<li><strong>Telegram Bot Token</strong>: per-user Telegram bot token (optional). Overrides the global token for your sessions.</li> <li><strong>Telegram Bot Token</strong>: per-user Telegram bot token (optional). Overrides the global token for your sessions.</li>
<li><strong>SSH Key</strong>: generate a personal ed25519 SSH key pair stored in your data folder. See below.</li>
</ul> </ul>
<h3 id="settings-ssh-key">SSH Key</h3>
<p>
The SSH Key section lets you generate an <strong>ed25519 key pair</strong> directly from the browser. The private key is stored in your data folder (<code>{your_folder}/.ssh/id_ed25519</code>) and never leaves the server. The public key is displayed so you can copy it to any remote server's <code>~/.ssh/authorized_keys</code>.
</p>
<ul>
<li>Click <strong>Generate SSH Key</strong> to create a new key pair. The registered email address is used as the key comment.</li>
<li>If a key already exists, the button changes to <strong>Regenerate</strong> - doing so replaces the existing key pair. Any remote servers that trusted the old public key will need to be updated.</li>
<li>Use the <strong>Copy</strong> button to copy the public key to the clipboard, then paste it into <code>~/.ssh/authorized_keys</code> on the remote server.</li>
<li>Agents with bash tool access can use this key to run <code>scp</code> or <code>ssh</code> commands against remote servers that have the public key in their <code>authorized_keys</code>.</li>
</ul>
<p class="help-note">
The SSH Key section only appears when a data folder is configured for your account. The global <code>~/.ssh/known_hosts</code> 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.
</p>
<h2 id="settings-personality">Personality</h2> <h2 id="settings-personality">Personality</h2>
<p> <p>
Edit <strong>SOUL.md</strong> (agent identity, values, communication style) and <strong>USER.md</strong> (owner context: name, location, preferences) directly in the browser. Changes take effect immediately no restart required. Edit <strong>SOUL.md</strong> (agent identity, values, communication style) and <strong>USER.md</strong> (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. Both files are injected into every system prompt in order: SOUL.md → date/time → USER.md → security rules.
</p> </p>
@@ -563,15 +605,15 @@ mcp = FastMCP(
<h2 id="settings-apikey">API Key <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2> <h2 id="settings-apikey">API Key <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
<p> <p>
Protects the REST API for external programmatic access (scripts, home automations, other services, Swagger). Protects the REST API for external programmatic access (scripts, home automations, other services, Swagger).
The <strong>web UI always works without a key</strong> a signed session cookie is set automatically on login. The <strong>web UI always works without a key</strong> - a signed session cookie is set automatically on login.
The API key is only required for: The API key is only required for:
</p> </p>
<ul> <ul>
<li>External tools and scripts calling <code>/api/*</code> directly</li> <li>External tools and scripts calling <code>/api/*</code> directly</li>
<li>Swagger UI (<a href="/docs">/docs</a>) click <strong>Authorize</strong> and enter the key</li> <li>Swagger UI (<a href="/docs">/docs</a>) - click <strong>Authorize</strong> and enter the key</li>
</ul> </ul>
<p> <p>
The raw key is shown <strong>once</strong> 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 <strong>once</strong> at generation time - copy it to your external tool. Only a SHA-256 hash is stored server-side. Regenerating invalidates the previous key immediately.
</p> </p>
<p> <p>
Use header <code>X-API-Key: &lt;key&gt;</code> or <code>Authorization: Bearer &lt;key&gt;</code> in external requests. Use header <code>X-API-Key: &lt;key&gt;</code> or <code>Authorization: Bearer &lt;key&gt;</code> in external requests.
@@ -606,7 +648,7 @@ mcp = FastMCP(
<h2>MFA Management</h2> <h2>MFA Management</h2>
<p> <p>
Users set up their own TOTP in <strong>Settings → Profile → Two-Factor Authentication</strong>. As admin, you can clear any user's MFA from the Users page (useful if they lose their authenticator). The <strong>Clear MFA</strong> button resets their TOTP secret they must set it up again on next login. Users set up their own TOTP in <strong>Settings → Profile → Two-Factor Authentication</strong>. As admin, you can clear any user's MFA from the Users page (useful if they lose their authenticator). The <strong>Clear MFA</strong> button resets their TOTP secret - they must set it up again on next login.
</p> </p>
<h2>User Filesystem Scoping</h2> <h2>User Filesystem Scoping</h2>
@@ -626,7 +668,7 @@ mcp = FastMCP(
<table class="help-api-table"> <table class="help-api-table">
<thead><tr><th>Key</th><th>Description</th></tr></thead> <thead><tr><th>Key</th><th>Description</th></tr></thead>
<tbody> <tbody>
<tr><td><code>system:paused</code></td><td>Kill switch set to <code>"1"</code> to pause all agent activity</td></tr> <tr><td><code>system:paused</code></td><td>Kill switch - set to <code>"1"</code> to pause all agent activity</td></tr>
<tr><td><code>system:max_tool_calls</code></td><td>Live override of MAX_TOOL_CALLS env var</td></tr> <tr><td><code>system:max_tool_calls</code></td><td>Live override of MAX_TOOL_CALLS env var</td></tr>
<tr><td><code>system:max_autonomous_runs_per_hour</code></td><td>Live override of MAX_AUTONOMOUS_RUNS_PER_HOUR</td></tr> <tr><td><code>system:max_autonomous_runs_per_hour</code></td><td>Live override of MAX_AUTONOMOUS_RUNS_PER_HOUR</td></tr>
<tr><td><code>system:audit_retention_days</code></td><td>Days to keep audit entries (0 = keep forever)</td></tr> <tr><td><code>system:audit_retention_days</code></td><td>Days to keep audit entries (0 = keep forever)</td></tr>
@@ -641,7 +683,7 @@ mcp = FastMCP(
<tr><td><code>system:canary_rotated_at</code></td><td>Timestamp of last canary rotation (read-only)</td></tr> <tr><td><code>system:canary_rotated_at</code></td><td>Timestamp of last canary rotation (read-only)</td></tr>
<tr><td><code>system:security_llm_screen_enabled</code></td><td>Option 3: LLM content screening enabled</td></tr> <tr><td><code>system:security_llm_screen_enabled</code></td><td>Option 3: LLM content screening enabled</td></tr>
<tr><td><code>system:security_llm_screen_model</code></td><td>Model for LLM screening (default: google/gemini-flash-1.5)</td></tr> <tr><td><code>system:security_llm_screen_model</code></td><td>Model for LLM screening (default: google/gemini-flash-1.5)</td></tr>
<tr><td><code>system:security_llm_screen_block</code></td><td>Option 3 block mode block vs flag on UNSAFE verdict</td></tr> <tr><td><code>system:security_llm_screen_block</code></td><td>Option 3 block mode - block vs flag on UNSAFE verdict</td></tr>
<tr><td><code>system:security_output_validation_enabled</code></td><td>Option 4: output validation for inbox sessions</td></tr> <tr><td><code>system:security_output_validation_enabled</code></td><td>Option 4: output validation for inbox sessions</td></tr>
<tr><td><code>system:security_truncation_enabled</code></td><td>Option 5: content truncation</td></tr> <tr><td><code>system:security_truncation_enabled</code></td><td>Option 5: content truncation</td></tr>
<tr><td><code>system:security_max_web_chars</code></td><td>Max chars from web fetch (default: 20 000)</td></tr> <tr><td><code>system:security_max_web_chars</code></td><td>Max chars from web fetch (default: 20 000)</td></tr>
@@ -650,7 +692,7 @@ mcp = FastMCP(
<tr><td><code>system:security_max_subject_chars</code></td><td>Max chars of email subject (default: 200)</td></tr> <tr><td><code>system:security_max_subject_chars</code></td><td>Max chars of email subject (default: 200)</td></tr>
<tr><td><code>telegram:bot_token</code></td><td>Global Telegram bot API token</td></tr> <tr><td><code>telegram:bot_token</code></td><td>Global Telegram bot API token</td></tr>
<tr><td><code>telegram:default_agent_id</code></td><td>UUID of agent for unmatched Telegram messages</td></tr> <tr><td><code>telegram:default_agent_id</code></td><td>UUID of agent for unmatched Telegram messages</td></tr>
<tr><td><code>pushover_app_token</code></td><td>Pushover App Token managed via <strong>Settings → Pushover</strong>, not this tab</td></tr> <tr><td><code>pushover_app_token</code></td><td>Pushover App Token - managed via <strong>Settings → Pushover</strong>, not this tab</td></tr>
<tr><td><code>brain:mcp_key</code></td><td>2nd Brain MCP authentication key</td></tr> <tr><td><code>brain:mcp_key</code></td><td>2nd Brain MCP authentication key</td></tr>
<tr><td><code>system:api_key_hash</code></td><td>SHA-256 hash of the external API key (raw key never stored)</td></tr> <tr><td><code>system:api_key_hash</code></td><td>SHA-256 hash of the external API key (raw key never stored)</td></tr>
<tr><td><code>system:api_key_created_at</code></td><td>Timestamp of last API key generation</td></tr> <tr><td><code>system:api_key_created_at</code></td><td>Timestamp of last API key generation</td></tr>
@@ -705,8 +747,8 @@ mcp = FastMCP(
<table class="help-api-table"> <table class="help-api-table">
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead> <thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
<tbody> <tbody>
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/api-key</code></td><td>Returns <code>{configured: bool, created_at}</code> never returns the raw key</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/settings/api-key</code></td><td>Returns <code>{configured: bool, created_at}</code> - never returns the raw key</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/api-key</code></td><td>Generate a new key returns <code>{key}</code> once only; invalidates previous key</td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/settings/api-key</code></td><td>Generate a new key - returns <code>{key}</code> once only; invalidates previous key</td></tr>
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/settings/api-key</code></td><td>Revoke the current key</td></tr> <tr><td><span class="http-del">DELETE</span></td><td><code>/api/settings/api-key</code></td><td>Revoke the current key</td></tr>
</tbody> </tbody>
</table> </table>
@@ -849,6 +891,8 @@ mcp = FastMCP(
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/files</code></td><td>Delete a file; param: <code>?path=</code>. Protected names (<code>memory_*</code>, <code>reasoning_*</code>) return 403.</td></tr> <tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/files</code></td><td>Delete a file; param: <code>?path=</code>. Protected names (<code>memory_*</code>, <code>reasoning_*</code>) return 403.</td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/files/download</code></td><td>Download a single file; param: <code>?path=</code></td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/my/files/download</code></td><td>Download a single file; param: <code>?path=</code></td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/files/download-zip</code></td><td>Download a folder as ZIP; param: <code>?path=</code></td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/my/files/download-zip</code></td><td>Download a folder as ZIP; param: <code>?path=</code></td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/files/upload</code></td><td>Upload one or more files to the user's folder; param: <code>?path=</code>, multipart form body</td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/files/view</code></td><td>Return text file contents (max 512 KB); param: <code>?path=</code></td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/data-folder</code></td><td>Return the user's provisioned data folder path</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/my/data-folder</code></td><td>Return the user's provisioned data folder path</td></tr>
</tbody> </tbody>
</table> </table>
@@ -864,7 +908,7 @@ mcp = FastMCP(
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/theme</code></td><td>Get current theme</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/my/theme</code></td><td>Get current theme</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/theme</code></td><td>Set theme <code>{theme_id}</code></td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/my/theme</code></td><td>Set theme <code>{theme_id}</code></td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/mfa/status</code></td><td>Whether MFA is enabled for the current user</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/my/mfa/status</code></td><td>Whether MFA is enabled for the current user</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/mfa/setup/begin</code></td><td>Start MFA setup returns QR code PNG (base64) and provisioning URI</td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/my/mfa/setup/begin</code></td><td>Start MFA setup - returns QR code PNG (base64) and provisioning URI</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/mfa/setup/confirm</code></td><td>Confirm setup with a valid TOTP code <code>{code}</code></td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/my/mfa/setup/confirm</code></td><td>Confirm setup with a valid TOTP code <code>{code}</code></td></tr>
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/mfa/disable</code></td><td>Disable MFA for the current user</td></tr> <tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/mfa/disable</code></td><td>Disable MFA for the current user</td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/caldav/config</code></td><td>Get per-user CalDAV &amp; CardDAV config</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/my/caldav/config</code></td><td>Get per-user CalDAV &amp; CardDAV config</td></tr>
@@ -876,6 +920,8 @@ mcp = FastMCP(
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/pushover</code></td><td>Save personal User Key <code>{user_key}</code></td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/my/pushover</code></td><td>Save personal User Key <code>{user_key}</code></td></tr>
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/pushover</code></td><td>Remove personal User Key</td></tr> <tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/pushover</code></td><td>Remove personal User Key</td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/telegram/whitelisted-chats</code></td><td>List Telegram chat IDs whitelisted for the current user</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/my/telegram/whitelisted-chats</code></td><td>List Telegram chat IDs whitelisted for the current user</td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/ssh/pubkey</code></td><td>Return the user's SSH public key (or <code>{exists: false}</code> if not generated yet)</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/ssh/generate</code></td><td>Generate (or regenerate) an ed25519 SSH key pair in the user's data folder; returns the public key</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -886,16 +932,16 @@ mcp = FastMCP(
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead> <thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
<tbody> <tbody>
<tr><td><span class="http-get">GET</span></td><td><code>/api/webhooks</code></td><td>List inbound webhook endpoints (admin)</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/webhooks</code></td><td>List inbound webhook endpoints (admin)</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/webhooks</code></td><td>Create endpoint returns token once (admin)</td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/webhooks</code></td><td>Create endpoint - returns token once (admin)</td></tr>
<tr><td><span class="http-put">PUT</span></td><td><code>/api/webhooks/{id}</code></td><td>Update name/description/agent/enabled (admin)</td></tr> <tr><td><span class="http-put">PUT</span></td><td><code>/api/webhooks/{id}</code></td><td>Update name/description/agent/enabled (admin)</td></tr>
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/webhooks/{id}</code></td><td>Delete endpoint (admin)</td></tr> <tr><td><span class="http-del">DELETE</span></td><td><code>/api/webhooks/{id}</code></td><td>Delete endpoint (admin)</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/webhooks/{id}/rotate</code></td><td>Regenerate token returns new token once (admin)</td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/webhooks/{id}/rotate</code></td><td>Regenerate token - returns new token once (admin)</td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/webhooks</code></td><td>List current user's webhook endpoints</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/my/webhooks</code></td><td>List current user's webhook endpoints</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/webhooks</code></td><td>Create personal webhook endpoint</td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/my/webhooks</code></td><td>Create personal webhook endpoint</td></tr>
<tr><td><span class="http-put">PUT</span></td><td><code>/api/my/webhooks/{id}</code></td><td>Update personal webhook endpoint</td></tr> <tr><td><span class="http-put">PUT</span></td><td><code>/api/my/webhooks/{id}</code></td><td>Update personal webhook endpoint</td></tr>
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/webhooks/{id}</code></td><td>Delete personal webhook endpoint</td></tr> <tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/webhooks/{id}</code></td><td>Delete personal webhook endpoint</td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/webhook/{token}</code></td><td>Trigger via GET param: <code>?q=message</code> (no auth)</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/webhook/{token}</code></td><td>Trigger via GET - param: <code>?q=message</code> (no auth)</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/webhook/{token}</code></td><td>Trigger via POST body: <code>{"message": "...", "async": true}</code> (no auth)</td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/webhook/{token}</code></td><td>Trigger via POST - body: <code>{"message": "...", "async": true}</code> (no auth)</td></tr>
<tr><td><span class="http-get">GET</span></td><td><code>/api/webhook-targets</code></td><td>List outbound webhook targets (admin)</td></tr> <tr><td><span class="http-get">GET</span></td><td><code>/api/webhook-targets</code></td><td>List outbound webhook targets (admin)</td></tr>
<tr><td><span class="http-post">POST</span></td><td><code>/api/webhook-targets</code></td><td>Create outbound target (admin)</td></tr> <tr><td><span class="http-post">POST</span></td><td><code>/api/webhook-targets</code></td><td>Create outbound target (admin)</td></tr>
<tr><td><span class="http-put">PUT</span></td><td><code>/api/webhook-targets/{id}</code></td><td>Update outbound target (admin)</td></tr> <tr><td><span class="http-put">PUT</span></td><td><code>/api/webhook-targets/{id}</code></td><td>Update outbound target (admin)</td></tr>
@@ -962,14 +1008,14 @@ mcp = FastMCP(
<h2>Core Principle</h2> <h2>Core Principle</h2>
<p> <p>
<strong>External input is data, never instructions.</strong> Email body text, calendar content, web page content, and file contents are all passed as <em>tool results</em> they are never injected into the system prompt where they could alter {{ agent_name }}'s instructions. <strong>External input is data, never instructions.</strong> Email body text, calendar content, web page content, and file contents are all passed as <em>tool results</em> - they are never injected into the system prompt where they could alter {{ agent_name }}'s instructions.
</p> </p>
<h2>Three DB-Managed Whitelists</h2> <h2>Three DB-Managed Whitelists</h2>
<ul> <ul>
<li><strong>Email whitelist</strong> {{ agent_name }} can only send email to addresses explicitly approved here</li> <li><strong>Email whitelist</strong> - {{ agent_name }} can only send email to addresses explicitly approved here</li>
<li><strong>Web whitelist (Tier 1)</strong> domains always accessible; subdomains included automatically</li> <li><strong>Web whitelist (Tier 1)</strong> - domains always accessible; subdomains included automatically</li>
<li><strong>Filesystem sandbox</strong> {{ agent_name }} can only read/write within declared directories (or a user's personal folder)</li> <li><strong>Filesystem sandbox</strong> - {{ agent_name }} can only read/write within declared directories (or a user's personal folder)</li>
</ul> </ul>
<p>Tier 2 web access (any URL) is only available in user-initiated chat sessions, never in autonomous agent runs.</p> <p>Tier 2 web access (any URL) is only available in user-initiated chat sessions, never in autonomous agent runs.</p>
@@ -980,21 +1026,21 @@ mcp = FastMCP(
<h2>Confirmation Flow</h2> <h2>Confirmation Flow</h2>
<p> <p>
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.
</p> </p>
<h2>Five Security Options</h2> <h2>Five Security Options</h2>
<ol> <ol>
<li><strong>Enhanced Sanitization</strong> removes known prompt-injection patterns from all external content before it reaches the agent</li> <li><strong>Enhanced Sanitization</strong> - removes known prompt-injection patterns from all external content before it reaches the agent</li>
<li><strong>Canary Token</strong> 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</li> <li><strong>Canary Token</strong> - 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</li>
<li><strong>LLM Content Screening</strong> a cheap secondary model screens fetched content for malicious instructions; operates in flag or block mode</li> <li><strong>LLM Content Screening</strong> - a cheap secondary model screens fetched content for malicious instructions; operates in flag or block mode</li>
<li><strong>Output Validation</strong> prevents inbox auto-reply loops by blocking outbound emails back to the triggering sender</li> <li><strong>Output Validation</strong> - prevents inbox auto-reply loops by blocking outbound emails back to the triggering sender</li>
<li><strong>Content Truncation</strong> enforces maximum character limits on web fetch, email, and file content to limit the attack surface of large malicious documents</li> <li><strong>Content Truncation</strong> - enforces maximum character limits on web fetch, email, and file content to limit the attack surface of large malicious documents</li>
</ol> </ol>
<h2>Audit Log</h2> <h2>Audit Log</h2>
<p> <p>
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 <a href="/audit">Audit Log</a>. 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 <a href="/audit">Audit Log</a>.
</p> </p>
<h2>Kill Switch</h2> <h2>Kill Switch</h2>
@@ -1004,7 +1050,7 @@ mcp = FastMCP(
<h2>No Credentials in Agent Context</h2> <h2>No Credentials in Agent Context</h2>
<p> <p>
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).
</p> </p>
</section> </section>
@@ -1036,16 +1082,16 @@ mcp = FastMCP(
</p> </p>
<p>Built-in sub-commands (e.g. for keyword <code>work</code>):</p> <p>Built-in sub-commands (e.g. for keyword <code>work</code>):</p>
<ul> <ul>
<li><code>/work pause</code> temporarily pause the email account's listener</li> <li><code>/work pause</code> - temporarily pause the email account's listener</li>
<li><code>/work resume</code> resume the listener</li> <li><code>/work resume</code> - resume the listener</li>
<li><code>/work status</code> show the account's current status</li> <li><code>/work status</code> - show the account's current status</li>
<li><code>/work &lt;any message&gt;</code> pass the message to the handling agent</li> <li><code>/work &lt;any message&gt;</code> - pass the message to the handling agent</li>
</ul> </ul>
<p class="help-note"> <p class="help-note">
Only the Telegram chat ID associated with the email account can use its keyword commands. Other chat IDs are rejected. Only the Telegram chat ID associated with the email account can use its keyword commands. Other chat IDs are rejected.
</p> </p>
<h2>Email Inbox Trigger Accounts</h2> <h2>Email Inbox - Trigger Accounts</h2>
<p> <p>
Trigger accounts use IMAP IDLE for instant push notification. When a new email arrives: Trigger accounts use IMAP IDLE for instant push notification. When a new email arrives:
</p> </p>
@@ -1064,15 +1110,15 @@ mcp = FastMCP(
<li>Non-whitelisted sender + no trigger → <strong>silently dropped</strong> (reveals nothing to the sender)</li> <li>Non-whitelisted sender + no trigger → <strong>silently dropped</strong> (reveals nothing to the sender)</li>
</ul> </ul>
<h2>Email Inbox Handling Accounts</h2> <h2>Email Inbox - Handling Accounts</h2>
<p> <p>
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: 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:
</p> </p>
<ul> <ul>
<li><strong>Email tool</strong> list, read, mark as read, move, create folders</li> <li><strong>Email tool</strong> - list, read, mark as read, move, create folders</li>
<li><strong>Filesystem tool</strong> scoped to the user's data folder (if configured)</li> <li><strong>Filesystem tool</strong> - scoped to the user's data folder (if configured)</li>
<li><strong>Memory files</strong> <code>memory_&lt;username&gt;.md</code> (persistent notes) and <code>reasoning_&lt;username&gt;.md</code> (append-only decision log) are injected into each run</li> <li><strong>Memory files</strong> - <code>memory_&lt;username&gt;.md</code> (persistent notes) and <code>reasoning_&lt;username&gt;.md</code> (append-only decision log) are injected into each run</li>
<li><strong>Telegram tool</strong> (optional) bound to the account's associated chat ID; reply messages automatically include a <code>/keyword &lt;reply&gt;</code> footer for easy follow-up</li> <li><strong>Telegram tool</strong> (optional) - bound to the account's associated chat ID; reply messages automatically include a <code>/keyword &lt;reply&gt;</code> footer for easy follow-up</li>
<li><strong>Pushover tool</strong> (optional, admin only)</li> <li><strong>Pushover tool</strong> (optional, admin only)</li>
</ul> </ul>
@@ -1081,8 +1127,8 @@ mcp = FastMCP(
Both Telegram and email inbox use the same trigger-matching algorithm: Both Telegram and email inbox use the same trigger-matching algorithm:
</p> </p>
<ul> <ul>
<li><strong>Case-insensitive</strong> <code>URGENT</code> matches <code>urgent</code></li> <li><strong>Case-insensitive</strong> - <code>URGENT</code> matches <code>urgent</code></li>
<li><strong>Order-independent</strong> all tokens in the trigger phrase must appear somewhere in the message, but not necessarily in sequence</li> <li><strong>Order-independent</strong> - all tokens in the trigger phrase must appear somewhere in the message, but not necessarily in sequence</li>
<li>Example: trigger phrase <code>daily report</code> matches <em>"Send me the report for the daily standup"</em> but also <em>"Daily summary report please"</em></li> <li>Example: trigger phrase <code>daily report</code> matches <em>"Send me the report for the daily standup"</em> but also <em>"Daily summary report please"</em></li>
</ul> </ul>
</section> </section>
+49 -4
View File
@@ -20,11 +20,11 @@
<button type="button" class="tab-btn" id="stab-caldav" onclick="switchSettingsTab('caldav')">DAV</button> <button type="button" class="tab-btn" id="stab-caldav" onclick="switchSettingsTab('caldav')">DAV</button>
<button type="button" class="tab-btn" id="stab-pushover" onclick="switchSettingsTab('pushover')">Pushover</button> <button type="button" class="tab-btn" id="stab-pushover" onclick="switchSettingsTab('pushover')">Pushover</button>
<button type="button" class="tab-btn" id="stab-inbox" onclick="switchSettingsTab('inbox')">Inbox</button> <button type="button" class="tab-btn" id="stab-inbox" onclick="switchSettingsTab('inbox')">Inbox</button>
<button type="button" class="tab-btn" id="stab-emailaccounts" onclick="switchSettingsTab('emailaccounts')">Email Accounts</button> <button type="button" class="tab-btn" id="stab-emailaccounts" onclick="switchSettingsTab('emailaccounts')">Email</button>
<button type="button" class="tab-btn" id="stab-telegram" onclick="switchSettingsTab('telegram')">Telegram</button> <button type="button" class="tab-btn" id="stab-telegram" onclick="switchSettingsTab('telegram')">Telegram</button>
<button type="button" class="tab-btn" id="stab-system" onclick="switchSettingsTab('system')">Personality</button> <button type="button" class="tab-btn" id="stab-system" onclick="switchSettingsTab('system')">Personality</button>
<button type="button" class="tab-btn" id="stab-brain" onclick="switchSettingsTab('brain')">2nd Brain</button> <button type="button" class="tab-btn" id="stab-brain" onclick="switchSettingsTab('brain')">2nd Brain</button>
<button type="button" class="tab-btn" id="stab-mcp" onclick="switchSettingsTab('mcp')">MCP Servers</button> <button type="button" class="tab-btn" id="stab-mcp" onclick="switchSettingsTab('mcp')">MCP</button>
<button type="button" class="tab-btn" id="stab-security" onclick="switchSettingsTab('security')">Security</button> <button type="button" class="tab-btn" id="stab-security" onclick="switchSettingsTab('security')">Security</button>
<button type="button" class="tab-btn" id="stab-branding" onclick="switchSettingsTab('branding')">Branding</button> <button type="button" class="tab-btn" id="stab-branding" onclick="switchSettingsTab('branding')">Branding</button>
<button type="button" class="tab-btn" id="stab-webhooks" onclick="switchSettingsTab('webhooks')">Webhooks</button> <button type="button" class="tab-btn" id="stab-webhooks" onclick="switchSettingsTab('webhooks')">Webhooks</button>
@@ -36,10 +36,10 @@
<button type="button" class="tab-btn active" id="ustab-apikeys" onclick="switchUserTab('apikeys')">API Keys</button> <button type="button" class="tab-btn active" id="ustab-apikeys" onclick="switchUserTab('apikeys')">API Keys</button>
<button type="button" class="tab-btn" id="ustab-personality" onclick="switchUserTab('personality')">Personality</button> <button type="button" class="tab-btn" id="ustab-personality" onclick="switchUserTab('personality')">Personality</button>
<button type="button" class="tab-btn" id="ustab-inbox" onclick="switchUserTab('inbox')">Inbox</button> <button type="button" class="tab-btn" id="ustab-inbox" onclick="switchUserTab('inbox')">Inbox</button>
<button type="button" class="tab-btn" id="ustab-emailaccounts" onclick="switchUserTab('emailaccounts')">Email Accounts</button> <button type="button" class="tab-btn" id="ustab-emailaccounts" onclick="switchUserTab('emailaccounts')">Email</button>
<button type="button" class="tab-btn" id="ustab-caldav" onclick="switchUserTab('caldav')">CalDAV / CardDAV</button> <button type="button" class="tab-btn" id="ustab-caldav" onclick="switchUserTab('caldav')">CalDAV / CardDAV</button>
<button type="button" class="tab-btn" id="ustab-telegram" onclick="switchUserTab('telegram')">Telegram</button> <button type="button" class="tab-btn" id="ustab-telegram" onclick="switchUserTab('telegram')">Telegram</button>
<button type="button" class="tab-btn" id="ustab-mcp" onclick="switchUserTab('mcp')">MCP Servers</button> <button type="button" class="tab-btn" id="ustab-mcp" onclick="switchUserTab('mcp')">MCP</button>
<button type="button" class="tab-btn" id="ustab-brain" onclick="switchUserTab('brain')">2nd Brain</button> <button type="button" class="tab-btn" id="ustab-brain" onclick="switchUserTab('brain')">2nd Brain</button>
<button type="button" class="tab-btn" id="ustab-pushover" onclick="switchUserTab('pushover')">Pushover</button> <button type="button" class="tab-btn" id="ustab-pushover" onclick="switchUserTab('pushover')">Pushover</button>
<button type="button" class="tab-btn" id="ustab-webhooks" onclick="switchUserTab('webhooks')">Webhooks</button> <button type="button" class="tab-btn" id="ustab-webhooks" onclick="switchUserTab('webhooks')">Webhooks</button>
@@ -1110,6 +1110,33 @@
</div> </div>
</section> </section>
<!-- File Upload Policy -->
<section style="margin-bottom:32px">
<h2 class="settings-section-title">File Upload Policy</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
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 (<code>known_hosts</code>, <code>authorized_keys</code>, <code>config</code>, <code>.gitignore</code>, etc.) and cannot be overridden here.
</p>
<div class="form-group" style="margin-bottom:12px">
<label>Allowed extensions</label>
<textarea id="sec-upload-extensions" class="form-input" rows="3"
placeholder="txt, md, pdf, jpg, png, py, js …"
style="font-family:var(--mono);font-size:12px;resize:vertical"></textarea>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;max-width:400px">
<div class="form-group" style="flex:1;min-width:140px;margin-bottom:0">
<label>Max file size <span style="color:var(--text-dim);font-size:11px">(MB, default 50)</span></label>
<input type="number" id="sec-upload-max-mb" class="form-input" min="1" max="2000" step="1">
</div>
<div class="form-group" style="flex:1;min-width:140px;margin-bottom:0">
<label>Max files per upload <span style="color:var(--text-dim);font-size:11px">(default 20)</span></label>
<input type="number" id="sec-upload-max-files" class="form-input" min="1" max="200" step="1">
</div>
</div>
</section>
<button type="submit" class="btn btn-primary">Save security settings</button> <button type="submit" class="btn btn-primary">Save security settings</button>
</form> </form>
@@ -1232,6 +1259,15 @@
<span id="data-folder-hint" style="font-size:12px;color:var(--text-dim)">Loading…</span> <span id="data-folder-hint" style="font-size:12px;color:var(--text-dim)">Loading…</span>
</section> </section>
<section style="max-width:480px;margin-top:32px;padding-top:32px;border-top:1px solid var(--border)">
<h3 class="settings-section-title">SSH Key</h3>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px;line-height:1.5">
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' <code>~/.ssh/authorized_keys</code> to allow agents to connect.
</p>
<div id="ssh-key-area">Loading…</div>
</section>
</div><!-- /spane-mfa --> </div><!-- /spane-mfa -->
<!-- ══════════════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════════════
@@ -1827,6 +1863,15 @@
<span id="data-folder-hint" style="font-size:12px;color:var(--text-dim)">Loading…</span> <span id="data-folder-hint" style="font-size:12px;color:var(--text-dim)">Loading…</span>
</section> </section>
<section style="max-width:480px;margin-top:32px;padding-top:32px;border-top:1px solid var(--border)">
<h3 class="settings-section-title">SSH Key</h3>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px;line-height:1.5">
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' <code>~/.ssh/authorized_keys</code> to allow agents to connect.
</p>
<div id="ssh-key-area">Loading…</div>
</section>
</div><!-- /uspane-mfa --> </div><!-- /uspane-mfa -->
{% endif %} {% endif %}