Updates follow a channel now. `stable` is the newest vX.Y.Z tag; `edge` is the
branch tip, which is what this did before. Stable is the default, because a
branch tip is not a release -- following one means deploying whatever was pushed
five minutes ago, possibly mid-feature, which is right for whoever builds this
and wrong for whoever runs it. The page can now say "running 1.0.0, 1.1.0
available" rather than showing two shas and leaving somebody to guess.
Read with git plumbing and never a forge API, for three reasons in the order
they bite. It would need a token on the deployment host -- a credential that can
reach the repository, sitting on a box, to answer a read-only question about
version numbers. It would tie this to one forge, so a fork on GitHub gets
nothing. And it breaks: checked against the Gitea this is developed on, `tea
whoami` works and `tea releases list` returns a 500 from a server-side panic
about token scopes, so a page resting on that endpoint would have shipped
already broken.
Release notes still travel, inside the annotated tag object, which
`git for-each-ref` reads with no API anywhere.
Two details that are only obvious after getting them wrong. A tag with a suffix
is not a release: git's version sort puts v1.1.0-rc1 *above* v1.1.0, so
accepting one would step a stable host onto a candidate on the strength of a
hyphen. And `--sort=-v:refname` rather than a lexical sort, which puts v1.9.0
above v1.10.0 and does it silently the first time a project reaches ten of
anything -- there is a test.
What is running is `git describe --tags --always`, so it reads "1.0.0" at a tag,
"1.0.0-7-gd4f56d" seven commits past one, and a bare sha before the first
release ever exists. That last case is what `--always` is for. When it lands
exactly on a tag whose name disagrees with __version__, the page says so: a tag
cut before the version bump names a release nobody can identify afterwards, and
the check costs no subprocess because both facts are already in hand.
update.sh resolves the channel the same way and detaches at the tag rather than
resetting -- a `reset --hard <tag>` while on main would move the local branch to
it, which is a rewrite of a ref nobody asked to rewrite. A host with no tags
falls back to the branch and says so, which is every host until the release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button cannot do the work, and that is the whole design. The service runs as
an unprivileged account, cannot restart itself, and should not be able to: a web
application that can restart its own service is one whose worst day is much
worse. So /admin/updates writes a file, and an opt-in systemd .path unit runs
deploy/update.sh as root.
Three properties hold it up, and each is a thing that could have been got wrong.
The request file carries nothing that reaches a command line -- no branch, no
ref, no arguments -- because the branch is baked into the unit at install time,
so pressing the button is always "deploy the branch this host was configured
with" and can never be "deploy something else". It is off unless somebody passes
INSTALL_UPDATE_HELPER=1, and re-running the installer without it removes both
units and the marker. And without the helper the page says so and prints the
manual command rather than writing a file nothing is watching, which would be a
button that reports success and does nothing.
The card that says all of this is rendered whether or not there is anything to
apply. It was inside the "there is an update" branch first, so an administrator
could not discover the helper was missing until the day they needed it, which is
the worst possible moment.
Opening the page makes no network request; Check is the one thing that fetches.
And it shows the log between, not a count: "3 behind" is a number somebody has to
go and look up, while the subjects are what decides whether this is worth
restarting for right now.
Docker is one stage, because there is nothing to build -- no Node, no compiled
assets. It bakes no secret key (one in an image is one every copy shares, and
rotating it makes stored API keys unreadable), no data, and no .git, so
/admin/updates inside a container correctly reports that it was not installed
from a checkout. Compose publishes on loopback and refuses to start without a
key. TLS in front is a constraint rather than a recommendation: the service
worker and the microphone both require HTTPS or localhost.
The image was built and run before this was committed, which is how the missing
COPY of LICENSE was found -- pyproject declares it and the build backend reads
it, so the failure reads like a packaging problem and is one line.
deploy/lxc-install.sh creates an unprivileged Debian container and runs the
existing installer inside it. A wrapper, not a second install path: a parallel
installer is two things to keep correct and one of them rots.
/healthz opens the database rather than only proving the socket is listening -- a
process that is up with a database it cannot open answers every page with a 500
-- and says nothing about what is here, being reachable without signing in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The one-time WebSocket check was skipped on exactly the deployments it
was added for: install.sh writes .deploy-env, so every host installed
before this release has none, and the check gave up rather than looking.
The port is in lembas.env, and the vhost is whichever conf.d file proxies
to it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A real shell on the chat's own connection, opened and closed like the
inspector and never beside it. The modes govern the model; what a person
types is theirs, since they hold the credential and could open the same
shell with an ssh client. The model cannot see the panel -- a button
copies the output you choose into the composer.
The session outlives the socket: closing the panel leaves a build
running, and coming back reattaches with the scrollback. Two tabs share
one shell and the smaller window decides the size. It ends on an idle
timeout, on deleting the chat, on disabling, moving or deleting the
connection, and on a restart -- which says why rather than quietly
opening a fresh shell that has lost the working directory.
The nginx template's `Connection ""` is right for SSE and fails every
WebSocket handshake, so `location /` now uses a `map $http_upgrade`;
update.sh grows a drift check for it, because the only symptom on a
stale vhost is a panel that cannot connect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
update.sh installed `[search]` only, so the release that added agent
connections shipped without asyncssh and the feature offered an install
hint on a machine that had just been told to install it.
The extras are now one variable, spelled the same way in install.sh and
update.sh, with a comment in both saying they have to stay in step. That is
the whole failure mode: an extra added to one of them is an extra existing
deployments silently miss.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
update.sh pulls the code and restarts, and says nothing about the unit --
so a host can run a new release under the old confinement and fail in a
way that points nowhere. Dropping ProtectKernelTunables is exactly such a
change: without it applied, an agent chat cannot start a sandbox at all.
It compares the *template* against the one last applied here rather than
against the installed file. An installed unit grows host-specific lines --
an ordering dependency on whatever serves the models, a note about how the
prefix is mounted -- and diffing the files would warn about those forever.
A warning that always fires is one nobody reads.
Reinstalling automatically would clobber those same lines, so it only says
so and leaves the merge to a person.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three features turn out to be one mechanism: a command waiting to be
approved, a question the model wants answered, and "this reply is waiting
for you" are all — stop the generation, put an interactive block in the
bubble, wait for a POST, carry on. So there is one primitive, and the only
thing using it so far is `ask_user`: a model can offer you a few answers
and a box to write your own.
The shell executor is not here yet. This lands first on purpose, because
it is the riskiest machinery in the feature and it is worth having working
before any subprocess exists to complicate it.
Two things about where the pause sits. It pauses a round, not a call: a
round's calls run together under a semaphore, and parking four coroutines
on four separate answers inside that gather would queue them behind each
other invisibly. And Stop had to be taught about it — `cancel` is read
between streamed chunks and there are no chunks while paused, so the
button did nothing at all until `request_stop` learned to resolve the
pause itself.
Also here: a risk class on every tool (read, write, execute), which is
what the four permission modes will be a table over, and the systemd unit
loses ProtectKernelTunables. That last one is not tidying — it
bind-mounts /proc/sys read-only, which stops bubblewrap mounting /proc at
all, and the obvious workaround would expose this process's environment
and with it the encryption key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four pieces of work.
**Installable.** A manifest carrying the instance name, PWA icons rasterised
from the existing mark at design time, a service worker and a themed offline
page. The worker caches the shell only and bails out on /api/, /auth/, /admin/
and anything accepting text/event-stream -- passing a reply stream through a
worker turns it into one delivery at the end, or nothing. It is served from
GET /sw.js rather than the static mount because a worker's scope is the path it
came from.
**Send and Stop are one button.** They were two, and the hidden one was never
hidden: `.btn` is display: inline-flex, which outranks the browser's own
`[hidden] { display: none }`, so Stop sat permanently beside Send. app.css now
forces the attribute to win -- every control toggled with `hidden` depended on
that -- and the composer renders one button carrying both icons, with ui.js
flipping data-composer-action and the type with it.
**Audio.** Speech to text and text to speech against any OpenAI-shaped
/v1/audio/* endpoint: dictate into the composer, have a reply read out.
Instance settings in Admin, per-reader overrides in Settings, with the voice
list discovered from the server where it offers one. Recorded audio is capped
and never written to disk -- it is not an attachment, it has no owner, and
nothing would ever sweep it.
**Web search, as a tool.** This is the tool loop PLAN.md described as the real
work: one reply is now a bounded sequence of requests rather than one. The model
asks, the tool runs, the result goes back and it is asked again, up to three
rounds. Providers are DuckDuckGo (no setup), SearXNG and Firecrawl.
Two decisions worth stating. Tools are only offered to models flagged `tools`,
because an endpoint without support rejects the whole request rather than
ignoring the array -- the same reason images only reach models flagged
`vision`. And tool results are not replayed as context on the next turn, for the
same reasons reasoning is not: the answer already contains what the model made
of them, and replaying stale results into every later request wastes the window
and reliably sends a small model into a search loop. The sources stay visible in
the transcript instead.
Search results are untrusted third-party text and are treated as such: escaped,
and only http/https URLs rendered as links.
338 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two things the running instance needed.
**Registration toggle.** Admin -> General, backed by a new settings table
group rather than the environment. LEMBAS_ALLOW_SIGNUP now seeds only the
initial value: once an administrator saves the setting, the stored value
wins. The alternative -- environment always winning -- means a toggle in
the UI silently reverts on the next restart, which is worse than not
offering one. Closing registration also removes the "Create one" link
from the sign-in page, so the link never leads somewhere that refuses.
**Password change**, on the user settings page. Changing a password
revokes every other session and immediately re-issues a cookie for the
current one: if the reason for the change is that somebody else knows
the password, leaving their session alive defeats the point, but signing
the user out of the tab they are standing in is merely rude.
**deploy/ is now host-agnostic.** This repository is public, so the unit
and vhost became templates with __PREFIX__ / __SITE_HOST__ / __APP_PORT__
substituted at install time, and every path, hostname and port moved to
environment variables. REPO_URL defaults to the checkout's own origin so
a fork deploys itself. Machine-specific values belong in private notes,
not here -- CLAUDE.md now says so.
83 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LLeMbas now runs end to end. Register, add an OpenAI-compatible
connection, and hold a real streaming conversation organised into
folders. Verified against the local llama-swap instance.
Streaming is the one genuinely tricky part. Sending a message returns
two HTML fragments -- the user bubble and an empty assistant bubble
carrying an sse-connect -- and that attribute is the ONLY thing that
starts a generation. Rendering an incomplete assistant message as a
streaming shell falls out of the same template, which means loading a
page whose last reply never finished simply picks it up again.
Details worth knowing about, each commented where it matters:
- SSE payloads are split across several data: lines. A raw newline in
one data: line truncates the event, which shows up the first time a
model emits a code block.
- Markdown is rendered server-side by the same helper for both the page
and the final streamed frame, so the two cannot disagree. The fence
renderer is replaced outright rather than using markdown-it's
highlight option, which re-wraps output in a second <pre>.
- escape_text is html.escape, not nh3.clean_text: it escapes character
by character, so escaping stream chunks separately equals escaping
the whole string.
- The stream opens its own session via session_scope(); it outlives the
request handler and the dependency-scoped session may be closed.
- Deleting a folder keeps the chats inside it (FK is SET NULL). Losing
a conversation to a mis-clicked folder delete is unforgivable.
- Login failures use one message for "no such account" and "wrong
password" so the form cannot enumerate registered addresses.
Also adds deploy/ for the gamebox install at https://chat.lan: system
unit, nginx vhost with buffering off (buffering on turns streaming into
one lump at the end), and install/update scripts following the same
service-user and /srv bind-mount conventions as llama-swap and comfyui.
70 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>