0a4531f02d
The local sandbox is dropped before it was built. Every hard problem in it came from running on the machine that holds the database and the encryption key: the service user cannot traverse /home, granting it needs ACLs, RLIMIT_NPROC is counted per uid so a fork bomb starves the server too, --size only applies to tmpfs so there is no disk quota, and the bind list is a standing invitation to widen until the sandbox is decoration. Over SSH, isolation is somebody's considered choice of host -- a throwaway container with one project mounted into it -- using tools far better at it than anything that could be built here. It is also the only version that is honestly multi-user: each person brings their own credentials and their own machine, and picks a project directory on it. So ProtectKernelTunables goes back. It was removed for exactly one reason, that bubblewrap cannot mount /proc without it, and that reason is gone. The agents settings group loses everything bwrap-shaped with it. What this costs, and the admin copy has to say so: there was a network:False switch that made exfiltration from a compromised reply impossible, and over SSH there is no equivalent, because the network belongs to the far side. The security of an agent chat is now the security of the host behind its profile, and LLeMbas cannot tell a scratch container from a live server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
8.2 KiB
Python
227 lines
8.2 KiB
Python
"""Instance-wide settings that administrators can change at runtime.
|
|
|
|
Distinct from ``lembas.config``, which holds deployment configuration read from
|
|
the environment at startup. Anything here is editable from the admin UI and
|
|
lives in the ``settings`` table.
|
|
|
|
Environment variables act as the *initial* value only. Once an administrator
|
|
sets something in the UI, the stored value wins -- otherwise a toggle in the
|
|
interface would silently revert on the next restart, which is worse than not
|
|
offering the toggle at all.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session as DBSession
|
|
|
|
from lembas.config import settings as env_settings
|
|
from lembas.db.models import Setting
|
|
|
|
GENERAL = "general"
|
|
AUDIO = "audio"
|
|
SEARCH = "search"
|
|
PROMPTS = "prompts"
|
|
AGENTS = "agents"
|
|
|
|
|
|
def _general_defaults() -> dict[str, Any]:
|
|
return {
|
|
"allow_signup": env_settings.allow_signup,
|
|
# When on, new accounts land in the `pending` role and cannot sign in
|
|
# until an administrator approves them. Reserved for the users pass.
|
|
"require_approval": False,
|
|
"instance_name": "LLeMbas",
|
|
# Applied to every chat that has no model or chat prompt of its
|
|
# own. See services.chat.effective_system_prompt.
|
|
"system_prompt": "",
|
|
# Percentage of a model's context length at which the earlier turns are
|
|
# summarised automatically. 0 turns it off; the Compact button still
|
|
# works, because a person asking for it does not need a threshold.
|
|
# Never fires for a model whose context_length is 0, since that is
|
|
# "unknown" rather than "small". See services/compaction.py.
|
|
"compact_threshold": 95,
|
|
}
|
|
|
|
|
|
def _agents_defaults() -> dict[str, Any]:
|
|
"""Agentic execution: running commands on a machine reached over SSH.
|
|
|
|
Deliberately never on the machine LLeMbas runs on. Executing here would put
|
|
the blast radius on the host holding the database and the encryption key,
|
|
and buying it back needs a sandbox, a bind list, a second unix account and
|
|
an argument about every one of them. Over SSH, isolation is somebody's
|
|
considered choice of host -- a throwaway container with one project mounted
|
|
into it, or a VM -- made with tools far better at it than anything that
|
|
could be built here.
|
|
"""
|
|
return {
|
|
# Off until an administrator turns it on. Not caution for its own sake:
|
|
# a model reads web pages, files and command output, all of them
|
|
# untrusted, so a shell is a capability somebody chooses on purpose.
|
|
"enabled": False,
|
|
# Per command.
|
|
"default_timeout": 60,
|
|
"max_timeout": 600,
|
|
"max_output_bytes": 64 * 1024,
|
|
# Per reply. See services/agent/policy.py:Limits.
|
|
"max_steps": 40,
|
|
"max_wall_seconds": 900,
|
|
"max_total_output_bytes": 1024 * 1024,
|
|
# How long a reply waits for someone to answer. Clamped on read: a zero
|
|
# here would park a background task forever.
|
|
"approval_timeout": 900,
|
|
"allow_default": ["file_read", "file_list", "ls *", "pwd", "git status"],
|
|
"deny_default": ["shutdown *", "reboot *", "mkfs*"],
|
|
"ask_free_text": True,
|
|
}
|
|
|
|
|
|
def _audio_defaults() -> dict[str, Any]:
|
|
"""Speech-to-text and text-to-speech endpoints.
|
|
|
|
Two separate endpoints rather than one, because they usually are: a local
|
|
install runs whisper.cpp for one and Kokoro for the other. Both speak the
|
|
OpenAI audio API, so the shape below is the same on each side.
|
|
"""
|
|
return {
|
|
"stt_enabled": False,
|
|
"stt_base_url": "",
|
|
"stt_api_key_encrypted": "",
|
|
"stt_model": "whisper-1",
|
|
# Empty means "let the server detect it", which is what whisper does
|
|
# best. A forced language is an override, not a default.
|
|
"stt_language": "",
|
|
"tts_enabled": False,
|
|
"tts_base_url": "",
|
|
"tts_api_key_encrypted": "",
|
|
"tts_model": "tts-1",
|
|
"tts_voice": "",
|
|
"tts_format": "mp3",
|
|
"tts_speed": 1.0,
|
|
# The instance-wide starting point for the per-user toggle, not a
|
|
# setting that forces anything on anyone.
|
|
"tts_autoplay": False,
|
|
}
|
|
|
|
|
|
def _search_defaults() -> dict[str, Any]:
|
|
return {
|
|
"enabled": False,
|
|
"provider": "ddgs",
|
|
"max_results": 5,
|
|
"region": "wt-wt",
|
|
"safesearch": "moderate",
|
|
"searxng_base_url": "",
|
|
"firecrawl_base_url": "https://api.firecrawl.dev",
|
|
"firecrawl_api_key_encrypted": "",
|
|
"timeout": 20.0,
|
|
# Whether saving a link may reach addresses on this machine or this
|
|
# network. Off, because a server that fetches any URL it is handed can
|
|
# be pointed at a router's admin page or at LLeMbas itself, and the URL
|
|
# can come from a model. See services/fetch.py.
|
|
"allow_private_fetch": False,
|
|
}
|
|
|
|
|
|
def _prompts_defaults() -> dict[str, Any]:
|
|
"""Deliberately carries no prompt text.
|
|
|
|
The default wording of every fragment lives in ``services/prompts.py``, and
|
|
only an administrator's *override* is stored here. That is what lets a later
|
|
release improve a default and have the improvement reach every instance that
|
|
never touched that fragment -- copying the defaults in here at first save
|
|
would freeze them forever.
|
|
"""
|
|
return {
|
|
# 0 means "use services.harness.MAX_HARNESS_CHARS".
|
|
"max_harness_chars": 0,
|
|
}
|
|
|
|
|
|
_DEFAULTS: dict[str, Any] = {
|
|
GENERAL: _general_defaults,
|
|
AUDIO: _audio_defaults,
|
|
SEARCH: _search_defaults,
|
|
PROMPTS: _prompts_defaults,
|
|
AGENTS: _agents_defaults,
|
|
}
|
|
|
|
|
|
def defaults(key: str = GENERAL) -> dict[str, Any]:
|
|
"""The built-in values for a settings group, with nothing stored applied."""
|
|
factory = _DEFAULTS.get(key)
|
|
return factory() if factory else {}
|
|
|
|
|
|
def get_group(db: DBSession, key: str = GENERAL) -> dict[str, Any]:
|
|
"""Stored settings for a group, with defaults filled in for absent keys."""
|
|
values = defaults(key)
|
|
row = db.get(Setting, key)
|
|
if row is not None and isinstance(row.value, dict):
|
|
values.update(row.value)
|
|
return values
|
|
|
|
|
|
def get(db: DBSession, name: str, *, key: str = GENERAL) -> Any:
|
|
return get_group(db, key).get(name)
|
|
|
|
|
|
def update(db: DBSession, changes: dict[str, Any], *, key: str = GENERAL) -> dict[str, Any]:
|
|
"""Merge changes into a settings group and persist them."""
|
|
row = db.get(Setting, key)
|
|
if row is None:
|
|
row = Setting(key=key, value={})
|
|
db.add(row)
|
|
|
|
# Reassigned rather than mutated: SQLAlchemy only reliably detects a change
|
|
# to a JSON column when the whole value is replaced.
|
|
row.value = {**(row.value or {}), **changes}
|
|
db.commit()
|
|
return get_group(db, key)
|
|
|
|
|
|
def replace(db: DBSession, values: dict[str, Any], *, key: str = GENERAL) -> dict[str, Any]:
|
|
"""Set a settings group to exactly these values, dropping anything absent.
|
|
|
|
`update` merges, which is right for a form that posts a fixed set of fields
|
|
and wrong for one whose fields come and go -- the prompt editor stores only
|
|
the fragments an administrator has actually changed, so "no longer present"
|
|
has to mean "no longer stored". There is no other way to delete a key.
|
|
"""
|
|
row = db.get(Setting, key)
|
|
if row is None:
|
|
row = Setting(key=key, value={})
|
|
db.add(row)
|
|
|
|
row.value = dict(values)
|
|
db.commit()
|
|
return get_group(db, key)
|
|
|
|
|
|
def signup_allowed(db: DBSession) -> bool:
|
|
return bool(get(db, "allow_signup"))
|
|
|
|
|
|
def audio(db: DBSession) -> dict[str, Any]:
|
|
return get_group(db, AUDIO)
|
|
|
|
|
|
def search(db: DBSession) -> dict[str, Any]:
|
|
return get_group(db, SEARCH)
|
|
|
|
|
|
def agents(db: DBSession) -> dict[str, Any]:
|
|
"""Agent settings, with the two numbers that must not be zero clamped.
|
|
|
|
`approval_timeout` of 0 would park a background task on a question nobody
|
|
is going to answer, and nothing else prunes a generation that is not
|
|
finished. Clamped on read rather than on save, so a value already stored by
|
|
an earlier version cannot bite either.
|
|
"""
|
|
values = get_group(db, AGENTS)
|
|
values["approval_timeout"] = min(max(int(values.get("approval_timeout") or 0), 60), 3600)
|
|
values["max_timeout"] = min(max(int(values.get("max_timeout") or 0), 1), 3600)
|
|
return values
|