Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c951185cdc |
+5
-3
@@ -15,14 +15,16 @@ RUN apt-get update \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" \
|
||||
> /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Playwright browser (Chromium) and its system dependencies
|
||||
RUN playwright install --with-deps chromium
|
||||
# Install Playwright browser (Chromium) and its system dependencies.
|
||||
# Skip with: docker build --build-arg NO_BROWSER=1 ... (~350MB smaller, browser tool unavailable)
|
||||
ARG NO_BROWSER=0
|
||||
RUN if [ "$NO_BROWSER" = "0" ]; then playwright install --with-deps chromium; fi
|
||||
|
||||
COPY server/ ./server/
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
aide:
|
||||
image: gitlab.pm/rune/oai-web:latest # use gitlab.pm/rune/oai-web:latest-no-browser to get a smaller image (172mb vs 572mb)
|
||||
image: image.gitlab.pm/rune/oai-web:latest
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
|
||||
+164
-2
@@ -849,11 +849,12 @@ async def list_all_runs(
|
||||
|
||||
@router.get("/agent-runs/{run_id}")
|
||||
async def get_agent_run(request: Request, run_id: str):
|
||||
_require_auth(request)
|
||||
user = _require_auth(request)
|
||||
from ..agents.tasks import get_run
|
||||
run = await get_run(run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
_check_agent_access(await agent_store.get_agent(run["agent_id"]), user)
|
||||
return run
|
||||
|
||||
|
||||
@@ -1475,6 +1476,49 @@ async def refresh_mcp_server(request: Request, server_id: str):
|
||||
|
||||
# ── Security settings ─────────────────────────────────────────────────────────
|
||||
|
||||
_UPLOAD_DEFAULT_EXTENSIONS: list[str] = [
|
||||
# Text / code
|
||||
"txt", "md", "csv", "json", "xml", "yaml", "yml", "toml", "ini", "conf", "cfg",
|
||||
"html", "css", "js", "ts", "jsx", "tsx", "py", "sh", "bash", "log", "sql",
|
||||
"env", "rst", "diff", "patch", "tsv", "nfo", "gitignore", "dockerfile",
|
||||
"rb", "go", "java", "c", "cpp", "h", "rs", "swift", "kt",
|
||||
# Images
|
||||
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "avif", "heic", "heif",
|
||||
# Documents
|
||||
"pdf",
|
||||
]
|
||||
|
||||
# Exact filenames (no extension) that are always allowed regardless of extension rules.
|
||||
# Checked by lowercase basename — not editable via the policy UI.
|
||||
_UPLOAD_ALLOWED_EXACT_NAMES: frozenset[str] = frozenset([
|
||||
"known_hosts", "authorized_keys", "config", # SSH
|
||||
"makefile", "procfile", "dockerfile", # build
|
||||
".env", ".gitignore", ".htaccess", # config dotfiles
|
||||
])
|
||||
_UPLOAD_DEFAULT_MAX_MB = 50
|
||||
_UPLOAD_DEFAULT_MAX_FILES = 20
|
||||
|
||||
|
||||
async def _get_upload_policy() -> dict:
|
||||
import json as _json
|
||||
raw_ext = await credential_store.get("system:upload_allowed_extensions")
|
||||
raw_mb = await credential_store.get("system:upload_max_file_size_mb")
|
||||
raw_n = await credential_store.get("system:upload_max_files")
|
||||
try:
|
||||
exts = _json.loads(raw_ext) if raw_ext else _UPLOAD_DEFAULT_EXTENSIONS
|
||||
except Exception:
|
||||
exts = _UPLOAD_DEFAULT_EXTENSIONS
|
||||
try:
|
||||
max_mb = int(raw_mb) if raw_mb else _UPLOAD_DEFAULT_MAX_MB
|
||||
except (ValueError, TypeError):
|
||||
max_mb = _UPLOAD_DEFAULT_MAX_MB
|
||||
try:
|
||||
max_files = int(raw_n) if raw_n else _UPLOAD_DEFAULT_MAX_FILES
|
||||
except (ValueError, TypeError):
|
||||
max_files = _UPLOAD_DEFAULT_MAX_FILES
|
||||
return {"allowed_extensions": exts, "max_file_size_mb": max_mb, "max_files": max_files}
|
||||
|
||||
|
||||
class SecuritySettingsIn(BaseModel):
|
||||
sanitize_enhanced: Optional[bool] = None
|
||||
canary_enabled: Optional[bool] = None
|
||||
@@ -1487,6 +1531,9 @@ class SecuritySettingsIn(BaseModel):
|
||||
max_email_chars: Optional[int] = None
|
||||
max_file_chars: Optional[int] = None
|
||||
max_subject_chars: Optional[int] = None
|
||||
upload_allowed_extensions: Optional[list[str]] = None
|
||||
upload_max_file_size_mb: Optional[int] = None
|
||||
upload_max_files: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/settings/security")
|
||||
@@ -1506,7 +1553,7 @@ async def get_security_settings(request: Request):
|
||||
sanitize_enhanced, canary_enabled, output_validation_enabled,
|
||||
truncation_enabled, llm_screen_enabled, llm_screen_block,
|
||||
max_web_chars, max_email_chars, max_file_chars, max_subject_chars,
|
||||
llm_screen_model,
|
||||
llm_screen_model, upload_policy,
|
||||
) = await asyncio_gather(
|
||||
_bool("system:security_sanitize_enhanced"),
|
||||
_bool("system:security_canary_enabled"),
|
||||
@@ -1519,6 +1566,7 @@ async def get_security_settings(request: Request):
|
||||
_int("system:security_max_file_chars", 20000),
|
||||
_int("system:security_max_subject_chars", 200),
|
||||
credential_store.get("system:security_llm_screen_model"),
|
||||
_get_upload_policy(),
|
||||
)
|
||||
return {
|
||||
"sanitize_enhanced": sanitize_enhanced,
|
||||
@@ -1532,6 +1580,7 @@ async def get_security_settings(request: Request):
|
||||
"llm_screen_enabled": llm_screen_enabled,
|
||||
"llm_screen_model": llm_screen_model or "google/gemini-flash-1.5",
|
||||
"llm_screen_block": llm_screen_block,
|
||||
**upload_policy,
|
||||
}
|
||||
|
||||
|
||||
@@ -1574,6 +1623,14 @@ async def save_security_settings(request: Request, body: SecuritySettingsIn):
|
||||
if body.llm_screen_block is not None:
|
||||
ops.append(credential_store.set("system:security_llm_screen_block", "1" if body.llm_screen_block else "0", "LLM screening block mode (vs flag mode)"))
|
||||
_invalidate_toggle_cache("system:security_llm_screen_block")
|
||||
if body.upload_allowed_extensions is not None:
|
||||
import json as _json
|
||||
exts = [e.lstrip(".").lower().strip() for e in body.upload_allowed_extensions if e.strip()]
|
||||
ops.append(credential_store.set("system:upload_allowed_extensions", _json.dumps(exts), "Allowed file extensions for user file uploads"))
|
||||
if body.upload_max_file_size_mb is not None:
|
||||
ops.append(credential_store.set("system:upload_max_file_size_mb", str(max(1, body.upload_max_file_size_mb)), "Max file size in MB for user uploads"))
|
||||
if body.upload_max_files is not None:
|
||||
ops.append(credential_store.set("system:upload_max_files", str(max(1, body.upload_max_files)), "Max number of files per upload request"))
|
||||
|
||||
if ops:
|
||||
await asyncio_gather(*ops)
|
||||
@@ -2780,6 +2837,58 @@ async def get_my_data_folder(request: Request):
|
||||
return {"data_folder": folder or ""}
|
||||
|
||||
|
||||
# ── Per-user SSH key ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/my/ssh/pubkey")
|
||||
async def get_my_ssh_pubkey(request: Request):
|
||||
user = _require_auth(request)
|
||||
from ..users import get_user_folder
|
||||
from pathlib import Path
|
||||
folder = await get_user_folder(user.id)
|
||||
if not folder:
|
||||
return {"exists": False, "pubkey": None, "no_folder": True}
|
||||
pubkey_path = Path(folder) / ".ssh" / "id_ed25519.pub"
|
||||
if not pubkey_path.exists():
|
||||
return {"exists": False, "pubkey": None}
|
||||
return {"exists": True, "pubkey": pubkey_path.read_text().strip()}
|
||||
|
||||
|
||||
@router.post("/my/ssh/generate")
|
||||
async def generate_my_ssh_key(request: Request):
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
user = _require_auth(request)
|
||||
body = await request.json()
|
||||
force = bool(body.get("force", False))
|
||||
from ..users import get_user_folder
|
||||
folder = await get_user_folder(user.id)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=400, detail="No user folder configured. Ask your administrator to set system:users_base_folder.")
|
||||
ssh_dir = Path(folder) / ".ssh"
|
||||
key_path = ssh_dir / "id_ed25519"
|
||||
pubkey_path = ssh_dir / "id_ed25519.pub"
|
||||
if key_path.exists() and not force:
|
||||
raise HTTPException(status_code=409, detail="SSH key already exists.")
|
||||
ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if force:
|
||||
key_path.unlink(missing_ok=True)
|
||||
pubkey_path.unlink(missing_ok=True)
|
||||
email = getattr(user, "email", "") or getattr(user, "username", "agent")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ssh-keygen", "-t", "ed25519", "-C", email,
|
||||
"-f", str(key_path), "-N", "",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"ssh-keygen failed: {stderr.decode()}")
|
||||
key_path.chmod(0o600)
|
||||
pubkey_path.chmod(0o644)
|
||||
ssh_dir.chmod(0o700)
|
||||
return {"pubkey": pubkey_path.read_text().strip()}
|
||||
|
||||
|
||||
# ── Per-user file browser ──────────────────────────────────────────────────────
|
||||
|
||||
import io as _io
|
||||
@@ -2943,6 +3052,59 @@ async def download_my_zip(request: Request, path: str = ""):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my/files/upload-policy")
|
||||
async def get_upload_policy(request: Request):
|
||||
_require_auth(request)
|
||||
return await _get_upload_policy()
|
||||
|
||||
|
||||
@router.post("/my/files/upload")
|
||||
async def upload_my_files(request: Request, path: str = ""):
|
||||
user = _require_auth(request)
|
||||
from ..users import get_user_folder
|
||||
base = await get_user_folder(user.id)
|
||||
if not base:
|
||||
raise HTTPException(status_code=404, detail="No files folder configured")
|
||||
target_dir = _resolve_user_path(base, path)
|
||||
if not _os.path.isdir(target_dir):
|
||||
raise HTTPException(status_code=400, detail="Target path is not a directory")
|
||||
|
||||
policy = await _get_upload_policy()
|
||||
allowed_exts = {e.lower() for e in policy["allowed_extensions"]}
|
||||
max_bytes = policy["max_file_size_mb"] * 1024 * 1024
|
||||
max_files = policy["max_files"]
|
||||
|
||||
form = await request.form()
|
||||
fields = [f for f in form.values() if hasattr(f, "filename") and f.filename]
|
||||
if len(fields) > max_files:
|
||||
raise HTTPException(status_code=400, detail=f"Too many files — max {max_files} per upload")
|
||||
|
||||
uploaded = []
|
||||
rejected = []
|
||||
for field in fields:
|
||||
safe_name = _os.path.basename(field.filename)
|
||||
if not safe_name:
|
||||
continue
|
||||
has_ext = "." in safe_name
|
||||
ext = safe_name.rsplit(".", 1)[-1].lower() if has_ext else ""
|
||||
name_lower = safe_name.lower()
|
||||
allowed = (ext in allowed_exts) if has_ext else (name_lower in _UPLOAD_ALLOWED_EXACT_NAMES)
|
||||
if not allowed:
|
||||
reason = f"File type .{ext} not allowed" if has_ext else f"Extensionless file '{safe_name}' not permitted"
|
||||
rejected.append({"name": safe_name, "reason": reason})
|
||||
continue
|
||||
content = await field.read()
|
||||
if len(content) > max_bytes:
|
||||
rejected.append({"name": safe_name, "reason": f"File exceeds {policy['max_file_size_mb']} MB limit"})
|
||||
continue
|
||||
dest = _resolve_user_path(base, _os.path.join(path, safe_name))
|
||||
with open(dest, "wb") as f:
|
||||
f.write(content)
|
||||
uploaded.append(safe_name)
|
||||
|
||||
return {"ok": True, "uploaded": uploaded, "rejected": rejected}
|
||||
|
||||
|
||||
@router.delete("/my/files")
|
||||
async def delete_my_file(request: Request, path: str):
|
||||
"""Delete a file from the user's data folder.
|
||||
|
||||
+270
-10
@@ -277,6 +277,12 @@ function _initPage(url) {
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
let _fbPath = "";
|
||||
let _uploadPolicy = null;
|
||||
const _UPLOAD_ALLOWED_EXACT_NAMES = new Set([
|
||||
"known_hosts", "authorized_keys", "config",
|
||||
"makefile", "procfile", "dockerfile",
|
||||
".env", ".gitignore", ".htaccess",
|
||||
]);
|
||||
|
||||
function _fbFmtSize(bytes) {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
@@ -332,6 +338,7 @@ async function fileBrowserNavigate(path) {
|
||||
const emptyEl = document.getElementById("file-empty");
|
||||
const noFolder = document.getElementById("file-no-folder");
|
||||
const zipBtn = document.getElementById("dl-zip-btn");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '<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";
|
||||
noFolder.style.display = "";
|
||||
zipBtn.style.display = "none";
|
||||
if (uploadBtn) uploadBtn.style.display = "none";
|
||||
document.getElementById("file-breadcrumb").innerHTML = "";
|
||||
} else {
|
||||
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();
|
||||
_fbBuildBreadcrumb(data.path);
|
||||
zipBtn.style.display = "";
|
||||
if (uploadBtn) uploadBtn.style.display = "";
|
||||
|
||||
if (!data.entries.length) {
|
||||
tableWrap.style.display = "none";
|
||||
@@ -514,6 +523,78 @@ async function fileBrowserDeleteFile(path, name) {
|
||||
}
|
||||
}
|
||||
|
||||
function fileBrowserUploadClick() {
|
||||
document.getElementById("file-upload-input").click();
|
||||
}
|
||||
|
||||
async function fileBrowserUploadFiles(input) {
|
||||
const files = Array.from(input.files);
|
||||
input.value = "";
|
||||
if (!files.length) return;
|
||||
|
||||
if (!_uploadPolicy) {
|
||||
const pr = await fetch("/api/my/files/upload-policy").catch(function() { return null; });
|
||||
if (pr && pr.ok) _uploadPolicy = await pr.json().catch(function() { return null; });
|
||||
}
|
||||
|
||||
const policy = _uploadPolicy || { allowed_extensions: [], max_file_size_mb: 50, max_files: 20 };
|
||||
const allowedExts = new Set((policy.allowed_extensions || []).map(function(e) { return e.toLowerCase(); }));
|
||||
const maxBytes = (policy.max_file_size_mb || 50) * 1024 * 1024;
|
||||
const maxFiles = policy.max_files || 20;
|
||||
|
||||
if (files.length > maxFiles) {
|
||||
alert("Too many files — max " + maxFiles + " per upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
const clientRejected = [];
|
||||
const fd = new FormData();
|
||||
for (const f of files) {
|
||||
const hasExt = f.name.includes(".");
|
||||
const ext = hasExt ? f.name.split(".").pop().toLowerCase() : "";
|
||||
const allowed = hasExt
|
||||
? (allowedExts.size === 0 || allowedExts.has(ext))
|
||||
: _UPLOAD_ALLOWED_EXACT_NAMES.has(f.name.toLowerCase());
|
||||
if (!allowed) {
|
||||
clientRejected.push(f.name + (hasExt ? " (type not allowed)" : " (extensionless file not permitted)"));
|
||||
continue;
|
||||
}
|
||||
if (f.size > maxBytes) {
|
||||
clientRejected.push(f.name + " (exceeds " + policy.max_file_size_mb + " MB limit)");
|
||||
continue;
|
||||
}
|
||||
fd.append("files", f);
|
||||
}
|
||||
|
||||
let uploadedCount = 0;
|
||||
const allRejected = [...clientRejected];
|
||||
if ([...fd.entries()].length > 0) {
|
||||
try {
|
||||
const r = await fetch("/api/my/files/upload?path=" + encodeURIComponent(_fbPath), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: fd,
|
||||
});
|
||||
const d = await r.json().catch(function() { return {}; });
|
||||
if (!r.ok) { alert("Upload failed: " + (d.detail || r.statusText)); return; }
|
||||
uploadedCount = (d.uploaded || []).length;
|
||||
for (const rej of (d.rejected || [])) allRejected.push(rej.name + " (" + rej.reason + ")");
|
||||
} catch(e) {
|
||||
alert("Upload failed: " + e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedCount > 0) {
|
||||
let msg = "Uploaded " + uploadedCount + " file" + (uploadedCount !== 1 ? "s" : "");
|
||||
if (allRejected.length) msg += ". Rejected: " + allRejected.join(", ");
|
||||
showFlash(msg);
|
||||
fileBrowserNavigate(_fbPath);
|
||||
} else if (allRejected.length) {
|
||||
alert("No files uploaded. Rejected:\n" + allRejected.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
const _FB_TEXT_EXTS = new Set([
|
||||
"md","txt","json","xml","yaml","yml","csv","html","htm","css","js","ts",
|
||||
"jsx","tsx","py","sh","bash","zsh","log","sql","toml","ini","conf","cfg",
|
||||
@@ -913,15 +994,95 @@ function renderMarkdown(text) {
|
||||
`<button class="copy-code-btn btn btn-ghost btn-small" onclick="copyCode(this)">Copy</button>` +
|
||||
`</div><pre><code>${esc(p.s)}</code></pre></div>`;
|
||||
}
|
||||
return _renderInline(p.s);
|
||||
return _renderBlocks(p.s);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function _renderBlocks(text) {
|
||||
const lines = text.split('\n');
|
||||
const out = [];
|
||||
let textBuf = [];
|
||||
let i = 0;
|
||||
|
||||
const flushText = () => {
|
||||
while (textBuf.length && !textBuf[0].trim()) textBuf.shift();
|
||||
while (textBuf.length && !textBuf[textBuf.length - 1].trim()) textBuf.pop();
|
||||
if (textBuf.length) out.push(textBuf.map(_renderInline).join('<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) {
|
||||
let s = esc(text);
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/<br\s*\/?>/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, "<br>");
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -1643,7 +1804,7 @@ function switchSettingsTab(name) {
|
||||
if (name === "inbox") { loadInboxStatus(); }
|
||||
if (name === "emailaccounts") { loadEmailAccounts(); }
|
||||
if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); initSshKey(); }
|
||||
if (name === "webhooks") { loadWebhooks(); loadWebhookTargets(); }
|
||||
if (name === "caldav") { loadAdminCaldav(); }
|
||||
if (name === "pushover") { loadAdminPushover(); }
|
||||
@@ -1790,7 +1951,7 @@ function switchUserTab(name) {
|
||||
if (name === "caldav") { loadMyCaldavConfig(); }
|
||||
if (name === "telegram") { loadMyTelegramConfig(); loadMyTelegramWhitelist(); loadMyTelegramTriggers(); }
|
||||
if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); initSshKey(); }
|
||||
if (name === "webhooks") { loadMyWebhooks(); loadMyWebhookTargets(); }
|
||||
if (name === "browser") { loadMyBrowserTrusted(); }
|
||||
if (name === "pushover") { loadMyPushover(); }
|
||||
@@ -2509,6 +2670,76 @@ async function loadDataFolder() {
|
||||
: "Not provisioned — ask your administrator to set system:users_base_folder in Credentials.";
|
||||
}
|
||||
|
||||
// ── Per-user SSH key ──────────────────────────────────────────────────────────
|
||||
|
||||
async function initSshKey() {
|
||||
const area = document.getElementById("ssh-key-area");
|
||||
if (!area) return;
|
||||
try {
|
||||
const r = await fetch("/api/my/ssh/pubkey");
|
||||
const d = await r.json();
|
||||
if (d.no_folder) {
|
||||
area.innerHTML = '<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 ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadMyCaldavConfig() {
|
||||
@@ -3778,12 +4009,19 @@ async function loadSecuritySettings() {
|
||||
if (mdEl && data.llm_screen_model) mdEl.value = data.llm_screen_model;
|
||||
const modeEl = document.getElementById("sec-llm-screen-mode");
|
||||
if (modeEl) modeEl.value = data.llm_screen_block ? "block" : "flag";
|
||||
const extEl = document.getElementById("sec-upload-extensions");
|
||||
if (extEl && data.allowed_extensions) extEl.value = data.allowed_extensions.join(", ");
|
||||
setNum("sec-upload-max-mb", data.max_file_size_mb);
|
||||
setNum("sec-upload-max-files", data.max_files);
|
||||
_uploadPolicy = { allowed_extensions: data.allowed_extensions, max_file_size_mb: data.max_file_size_mb, max_files: data.max_files };
|
||||
} catch { /* ignore */ }
|
||||
|
||||
form.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const getChk = id => { const el = document.getElementById(id); return el ? el.checked : null; };
|
||||
const getNum = id => { const el = document.getElementById(id); return el ? parseInt(el.value, 10) : null; };
|
||||
const extRaw = (document.getElementById("sec-upload-extensions") || {}).value || "";
|
||||
const extList = extRaw.split(/[\s,]+/).map(s => s.replace(/^\./, "").toLowerCase()).filter(Boolean);
|
||||
const body = {
|
||||
sanitize_enhanced: getChk("sec-sanitize-enhanced"),
|
||||
truncation_enabled: getChk("sec-truncation-enabled"),
|
||||
@@ -3796,8 +4034,10 @@ async function loadSecuritySettings() {
|
||||
llm_screen_enabled: getChk("sec-llm-screen-enabled"),
|
||||
llm_screen_model: (() => { const el = document.getElementById("sec-llm-screen-model"); return el ? el.value : null; })(),
|
||||
llm_screen_block: (() => { const el = document.getElementById("sec-llm-screen-mode"); return el ? el.value === "block" : null; })(),
|
||||
upload_allowed_extensions: extList.length ? extList : null,
|
||||
upload_max_file_size_mb: getNum("sec-upload-max-mb"),
|
||||
upload_max_files: getNum("sec-upload-max-files"),
|
||||
};
|
||||
// Remove null/NaN values
|
||||
for (const k of Object.keys(body)) {
|
||||
if (body[k] === null || (typeof body[k] === "number" && isNaN(body[k]))) delete body[k];
|
||||
}
|
||||
@@ -3806,8 +4046,12 @@ async function loadSecuritySettings() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) showFlash("Security settings saved ✓");
|
||||
else showFlash("Error saving security settings");
|
||||
if (r.ok) {
|
||||
showFlash("Security settings saved ✓");
|
||||
_uploadPolicy = null; // bust cache so files page picks up new policy
|
||||
} else {
|
||||
showFlash("Error saving security settings");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5465,8 +5709,24 @@ function helpSearch(query) {
|
||||
// Sync TOC link visibility
|
||||
document.querySelectorAll(".toc-list a[href^='#']").forEach(a => {
|
||||
const id = a.getAttribute("href").slice(1);
|
||||
const section = document.getElementById(id);
|
||||
const visible = !q || !section || section.style.display !== "none";
|
||||
const el = document.getElementById(id);
|
||||
let visible;
|
||||
if (!q || !el) {
|
||||
visible = true;
|
||||
} else if (el.matches("section[data-section]")) {
|
||||
visible = el.style.display !== "none";
|
||||
} else {
|
||||
// Heading inside a section: scan from this element to the next sibling
|
||||
// of the same tag to check if the query appears in that content block.
|
||||
const tag = el.tagName;
|
||||
visible = false;
|
||||
let node = el;
|
||||
while (node) {
|
||||
if (node.textContent.toLowerCase().includes(q)) { visible = true; break; }
|
||||
node = node.nextElementSibling;
|
||||
if (node && node.tagName === tag) break;
|
||||
}
|
||||
}
|
||||
a.closest("li").style.display = visible ? "" : "none";
|
||||
});
|
||||
|
||||
|
||||
@@ -193,6 +193,25 @@ body {
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
/* Markdown headings */
|
||||
.message-bubble .md-h { margin: 10px 0 4px; font-weight: 600; line-height: 1.3; }
|
||||
.message-bubble h1.md-h { font-size: 1.4em; }
|
||||
.message-bubble h2.md-h { font-size: 1.2em; }
|
||||
.message-bubble h3.md-h { font-size: 1.05em; }
|
||||
.message-bubble h4.md-h, .message-bubble h5.md-h, .message-bubble h6.md-h { font-size: 1em; }
|
||||
.message-bubble .md-h:first-child { margin-top: 0; }
|
||||
|
||||
/* Markdown lists */
|
||||
.message-bubble .md-list { margin: 6px 0; padding-left: 22px; }
|
||||
.message-bubble .md-list li { margin: 2px 0; }
|
||||
|
||||
/* Markdown tables */
|
||||
.md-table-wrap { overflow-x: auto; margin: 8px 0; }
|
||||
.md-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
.md-table th, .md-table td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; vertical-align: top; }
|
||||
.md-table thead th { background: var(--bg3); font-weight: 600; }
|
||||
.md-table tbody tr:nth-child(even) { background: color-mix(in srgb, var(--bg3) 40%, transparent); }
|
||||
|
||||
/* Copy response button (below assistant bubble, visible on hover) */
|
||||
.copy-response-btn {
|
||||
font-size: 11px;
|
||||
@@ -905,7 +924,21 @@ tr:hover td { background: var(--bg2); }
|
||||
/* Tab bar scrollable */
|
||||
.tabs { overflow-x: auto; flex-wrap: nowrap; }
|
||||
|
||||
/* Filter bar */
|
||||
/* Filter bar: inputs fill available width, pairs stack two-per-row */
|
||||
.filter-bar { gap: 6px; }
|
||||
.filter-bar .form-input { min-width: 0; }
|
||||
.filter-bar .form-group { flex: 1 1 calc(50% - 6px); min-width: 120px; }
|
||||
.filter-bar .form-input { width: 100% !important; min-width: 0; box-sizing: border-box; }
|
||||
.filter-bar-actions { flex: 1 1 100%; }
|
||||
|
||||
/* Chats page: action buttons wrap below content on narrow screens */
|
||||
.chat-row { flex-wrap: wrap !important; }
|
||||
.chat-row-actions { flex: 1 1 100% !important; padding-left: 27px; }
|
||||
|
||||
/* Agent detail header: name + buttons wrap */
|
||||
.agent-detail-header { flex-wrap: wrap !important; gap: 12px; }
|
||||
.agent-detail-header > div:last-child { flex-shrink: 0; }
|
||||
|
||||
/* Fullscreen prompt editor on mobile */
|
||||
.pe-kbd-hint { display: none; }
|
||||
#pe-textarea { padding: 16px !important; font-size: 14px !important; }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block content %}
|
||||
<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>
|
||||
<a href="/agents" onclick="event.preventDefault();navigateTo('/agents')"
|
||||
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>
|
||||
</div>
|
||||
<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-primary" id="pe-save-btn" onclick="savePromptEditor()">Save</button>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<label>Session ID</label>
|
||||
<input type="text" id="filter-session" class="form-input" placeholder="prefix…">
|
||||
</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-ghost" id="filter-reset">Reset</button>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
|
||||
<div class="sidebar-logo-text">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ function renderChats(data) {
|
||||
${model}
|
||||
</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}"
|
||||
data-id="${c.id}" onclick="localStorage.setItem('current_session_id',this.dataset.id)">Open</a>
|
||||
<button class="btn btn-ghost btn-small"
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<!-- Header -->
|
||||
<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>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button id="upload-btn" class="btn btn-ghost btn-small" onclick="fileBrowserUploadClick()"
|
||||
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="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
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"
|
||||
@@ -18,6 +29,9 @@
|
||||
Download folder as ZIP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="file-upload-input" multiple style="display:none"
|
||||
onchange="fileBrowserUploadFiles(this)">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div id="file-breadcrumb"
|
||||
|
||||
+154
-108
@@ -23,7 +23,12 @@
|
||||
<li><a href="#chat-badges">Capability Badges</a></li>
|
||||
</ul>
|
||||
</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>
|
||||
<a href="#agents">Agents</a>
|
||||
<ul>
|
||||
@@ -92,7 +97,7 @@
|
||||
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
|
||||
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.
|
||||
</p>
|
||||
|
||||
@@ -100,7 +105,7 @@
|
||||
<ul>
|
||||
<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><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>
|
||||
</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>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 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 configure email accounts and Telegram via their respective Settings tabs</li>
|
||||
</ol>
|
||||
@@ -119,7 +124,7 @@
|
||||
<h2>Key Concepts</h2>
|
||||
<dl>
|
||||
<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>
|
||||
<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>
|
||||
@@ -127,7 +132,7 @@
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -143,7 +148,7 @@
|
||||
<h2>Sending Messages</h2>
|
||||
<p>
|
||||
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>
|
||||
|
||||
<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:
|
||||
</p>
|
||||
<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>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -169,18 +174,18 @@
|
||||
<h2 id="chat-badges">Capability Badges</h2>
|
||||
<p>Small badges in the status bar show what the active model supports:</p>
|
||||
<ul>
|
||||
<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>Tools</strong> — supports tool/function calling</li>
|
||||
<li>🌐 <strong>Online</strong> — has live web access built in</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>Tools</strong> - supports tool/function calling</li>
|
||||
<li>🌐 <strong>Online</strong> - has live web access built in</li>
|
||||
</ul>
|
||||
|
||||
<h2>Tool Indicators</h2>
|
||||
<p>While the agent is working, small badges appear below each message:</p>
|
||||
<ul>
|
||||
<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(--red)">●</span> <strong>Solid red</strong> — tool failed or returned an error</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(--red)">●</span> <strong>Solid red</strong> - tool failed or returned an error</li>
|
||||
</ul>
|
||||
|
||||
<h2>Confirmation Modal</h2>
|
||||
@@ -210,16 +215,38 @@
|
||||
|
||||
<h2>Downloading</h2>
|
||||
<ul>
|
||||
<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>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>
|
||||
</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>
|
||||
<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.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<h2>No Folder Configured?</h2>
|
||||
@@ -232,7 +259,7 @@
|
||||
<section id="agents" data-section>
|
||||
<h1>Agents</h1>
|
||||
<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 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>.
|
||||
@@ -241,18 +268,18 @@
|
||||
<h2 id="agents-creating">Creating an Agent</h2>
|
||||
<p>Click <strong>New Agent</strong> on the Agents page. Required fields:</p>
|
||||
<ul>
|
||||
<li><strong>Name</strong> — displayed in the UI and logs</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>Name</strong> - displayed in the UI and logs</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>
|
||||
</ul>
|
||||
<p>Optional fields:</p>
|
||||
<ul>
|
||||
<li><strong>Description</strong> — shown in the agent list for reference</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>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>Prompt Mode</strong> — controls how the prompt is composed (see below)</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>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>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>
|
||||
</ul>
|
||||
|
||||
<h2 id="agents-schedule">Scheduling</h2>
|
||||
@@ -260,10 +287,10 @@
|
||||
<pre>minute hour day-of-month month day-of-week</pre>
|
||||
<p>Examples:</p>
|
||||
<ul>
|
||||
<li><code>0 8 * * 1-5</code> — weekdays at 08:00</li>
|
||||
<li><code>*/15 * * * *</code> — every 15 minutes</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>0 8 * * 1-5</code> - weekdays at 08:00</li>
|
||||
<li><code>*/15 * * * *</code> - every 15 minutes</li>
|
||||
<li><code>0 9 * * 1</code> - every Monday at 09:00</li>
|
||||
<li><code>30 18 * * *</code> - every day at 18:30</li>
|
||||
</ul>
|
||||
<p>
|
||||
Use the <strong>Enable / Disable</strong> toggle to pause a schedule without deleting the agent.
|
||||
@@ -278,12 +305,12 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<h2 id="agents-tools">Tool Restrictions</h2>
|
||||
<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>
|
||||
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>
|
||||
<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>
|
||||
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>
|
||||
<h1>Monitors</h1>
|
||||
<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>
|
||||
|
||||
<h2 id="monitors-pages">Page Watchers</h2>
|
||||
@@ -317,18 +344,18 @@
|
||||
</p>
|
||||
<p>Fields when creating a page watcher:</p>
|
||||
<ul>
|
||||
<li><strong>Name</strong> — displayed in the monitor list</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>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>Notification mode</strong> — <code>agent</code> (dispatch the agent), <code>pushover</code> (send a push notification), or <code>both</code></li>
|
||||
<li><strong>Name</strong> - displayed in the monitor list</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>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>Notification mode</strong> - <code>agent</code> (dispatch the agent), <code>pushover</code> (send a push notification), or <code>both</code></li>
|
||||
</ul>
|
||||
<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.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<h2 id="monitors-rss">RSS Feeds</h2>
|
||||
@@ -337,12 +364,12 @@
|
||||
</p>
|
||||
<p>Fields when creating an RSS monitor:</p>
|
||||
<ul>
|
||||
<li><strong>Name</strong> — displayed in the monitor list</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>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>Notification mode</strong> — <code>agent</code>, <code>pushover</code>, or <code>both</code></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>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>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>
|
||||
</ul>
|
||||
<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.
|
||||
@@ -362,7 +389,7 @@
|
||||
<li>Expose an SSE endpoint at <code>/sse</code></li>
|
||||
<li>Use <strong>SSE transport</strong> (not stdio)</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:
|
||||
<pre>from mcp.server.transport_security import TransportSecuritySettings
|
||||
mcp = FastMCP(
|
||||
@@ -373,7 +400,7 @@ mcp = FastMCP(
|
||||
)</pre>
|
||||
Without this, the server rejects requests with a <code>421 Misdirected Request</code> error.
|
||||
</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>
|
||||
|
||||
<h2>Adding an MCP Server</h2>
|
||||
@@ -382,10 +409,10 @@ mcp = FastMCP(
|
||||
<li>Click <strong>Add Server</strong></li>
|
||||
<li>Enter:
|
||||
<ul>
|
||||
<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>Transport</strong> — select <code>sse</code></li>
|
||||
<li><strong>API Key</strong> — optional bearer token if the server requires authentication</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>Transport</strong> - select <code>sse</code></li>
|
||||
<li><strong>API Key</strong> - optional bearer token if the server requires authentication</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Click <strong>Save</strong></li>
|
||||
@@ -395,7 +422,7 @@ mcp = FastMCP(
|
||||
<h2>Tool Namespacing</h2>
|
||||
<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.
|
||||
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>
|
||||
|
||||
<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>
|
||||
<ul>
|
||||
<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>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>
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
<h2 id="settings-dav">DAV (CalDAV & CardDAV) <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<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>
|
||||
<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>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>
|
||||
</ul>
|
||||
|
||||
@@ -446,7 +473,7 @@ mcp = FastMCP(
|
||||
Pushover sends push notifications to iOS and Android devices.
|
||||
</p>
|
||||
<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>
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -457,7 +484,7 @@ mcp = FastMCP(
|
||||
</p>
|
||||
<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>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>
|
||||
|
||||
<h2 id="settings-emailaccounts">Email Accounts</h2>
|
||||
@@ -491,10 +518,10 @@ mcp = FastMCP(
|
||||
{% if not (current_user and current_user.is_admin) %}
|
||||
<h2 id="settings-caldav">CalDAV / CardDAV</h2>
|
||||
<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>
|
||||
<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>Allow contact writes</strong>: when enabled, agents can create, update, and delete contacts.</li>
|
||||
<li><strong>Test buttons</strong>: verify connectivity before saving.</li>
|
||||
@@ -502,18 +529,18 @@ mcp = FastMCP(
|
||||
|
||||
<h2 id="settings-pushover">Pushover</h2>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<h2 id="settings-webhooks">Webhooks</h2>
|
||||
<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>
|
||||
<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 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>
|
||||
</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>
|
||||
@@ -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>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>SSH Key</strong>: generate a personal ed25519 SSH key pair stored in your data folder. See below.</li>
|
||||
</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>
|
||||
<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.
|
||||
</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>
|
||||
<p>
|
||||
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:
|
||||
</p>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
Use header <code>X-API-Key: <key></code> or <code>Authorization: Bearer <key></code> in external requests.
|
||||
@@ -606,7 +648,7 @@ mcp = FastMCP(
|
||||
|
||||
<h2>MFA Management</h2>
|
||||
<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>
|
||||
|
||||
<h2>User Filesystem Scoping</h2>
|
||||
@@ -626,7 +668,7 @@ mcp = FastMCP(
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Key</th><th>Description</th></tr></thead>
|
||||
<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_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>
|
||||
@@ -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: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_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_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>
|
||||
@@ -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>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>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>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>
|
||||
@@ -705,8 +747,8 @@ mcp = FastMCP(
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<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-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-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-del">DELETE</span></td><td><code>/api/settings/api-key</code></td><td>Revoke the current key</td></tr>
|
||||
</tbody>
|
||||
</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-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-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>
|
||||
</tbody>
|
||||
</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-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-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-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 & 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-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/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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -886,16 +932,16 @@ mcp = FastMCP(
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<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-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-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-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-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-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>/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-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-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>
|
||||
<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>
|
||||
|
||||
<h2>Three DB-Managed Whitelists</h2>
|
||||
<ul>
|
||||
<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>Filesystem sandbox</strong> — {{ agent_name }} can only read/write within declared directories (or a user's personal folder)</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>Filesystem sandbox</strong> - {{ agent_name }} can only read/write within declared directories (or a user's personal folder)</li>
|
||||
</ul>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<h2>Five Security Options</h2>
|
||||
<ol>
|
||||
<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>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>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>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>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>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>
|
||||
|
||||
<h2>Audit Log</h2>
|
||||
<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>
|
||||
|
||||
<h2>Kill Switch</h2>
|
||||
@@ -1004,7 +1050,7 @@ mcp = FastMCP(
|
||||
|
||||
<h2>No Credentials in Agent Context</h2>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -1036,16 +1082,16 @@ mcp = FastMCP(
|
||||
</p>
|
||||
<p>Built-in sub-commands (e.g. for keyword <code>work</code>):</p>
|
||||
<ul>
|
||||
<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 status</code> — show the account's current status</li>
|
||||
<li><code>/work <any message></code> — pass the message to the handling agent</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 status</code> - show the account's current status</li>
|
||||
<li><code>/work <any message></code> - pass the message to the handling agent</li>
|
||||
</ul>
|
||||
<p class="help-note">
|
||||
Only the Telegram chat ID associated with the email account can use its keyword commands. Other chat IDs are rejected.
|
||||
</p>
|
||||
|
||||
<h2>Email Inbox — Trigger Accounts</h2>
|
||||
<h2>Email Inbox - Trigger Accounts</h2>
|
||||
<p>
|
||||
Trigger accounts use IMAP IDLE for instant push notification. When a new email arrives:
|
||||
</p>
|
||||
@@ -1064,15 +1110,15 @@ mcp = FastMCP(
|
||||
<li>Non-whitelisted sender + no trigger → <strong>silently dropped</strong> (reveals nothing to the sender)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Email Inbox — Handling Accounts</h2>
|
||||
<h2>Email Inbox - Handling Accounts</h2>
|
||||
<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:
|
||||
</p>
|
||||
<ul>
|
||||
<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>Memory files</strong> — <code>memory_<username>.md</code> (persistent notes) and <code>reasoning_<username>.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 <reply></code> footer for easy follow-up</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>Memory files</strong> - <code>memory_<username>.md</code> (persistent notes) and <code>reasoning_<username>.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 <reply></code> footer for easy follow-up</li>
|
||||
<li><strong>Pushover tool</strong> (optional, admin only)</li>
|
||||
</ul>
|
||||
|
||||
@@ -1081,8 +1127,8 @@ mcp = FastMCP(
|
||||
Both Telegram and email inbox use the same trigger-matching algorithm:
|
||||
</p>
|
||||
<ul>
|
||||
<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>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>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>
|
||||
</section>
|
||||
|
||||
@@ -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-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-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-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-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-branding" onclick="switchSettingsTab('branding')">Branding</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" 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-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-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-pushover" onclick="switchUserTab('pushover')">Pushover</button>
|
||||
<button type="button" class="tab-btn" id="ustab-webhooks" onclick="switchUserTab('webhooks')">Webhooks</button>
|
||||
@@ -1110,6 +1110,33 @@
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
|
||||
@@ -1232,6 +1259,15 @@
|
||||
<span id="data-folder-hint" style="font-size:12px;color:var(--text-dim)">Loading…</span>
|
||||
</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 -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
@@ -1827,6 +1863,15 @@
|
||||
<span id="data-folder-hint" style="font-size:12px;color:var(--text-dim)">Loading…</span>
|
||||
</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 -->
|
||||
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user