Commit Graph

10 Commits

Author SHA1 Message Date
Homer 546f8a30d7 Boundaries that were supposed to hold
The security pass. Six findings, none reachable by visiting the site and
every one a boundary this codebase says it keeps.

A subagent is pinned to a list of read-only commands, in every mode,
unattended, with no card anybody could approve -- and `find *` was on it.
find writes files with -fprintf, runs programs with -exec and removes them
with -delete, and none of that needs a character the metacharacter guard
refuses. A page the model had just read could ask for a helper and get a
key into authorized_keys, from Plan mode, which promises to change
nothing. Refused in `subject()` rather than trimmed from the list: a
pattern cannot say "and no dangerous flags", and "this one looks
read-only" is exactly what put find there.

The loopback guard missed `0.0.0.0`, which is not is_loopback but does
connect to localhost -- so it answered a *decided* False and skipped the
DNS half too. The one spelling of "this machine" that walked past a guard
whose whole job is that sentence.

Twice in the update helper, which is the one place this deliberately
crosses a privilege boundary: root ran a script the service account owns,
and root sourced a file that account can replace. Either turns a
compromise of the web application into root. The first needed no
compromise at all -- a pull happens as the service user and root runs
whatever it fetched, so control of the branch was control of root. The
old test asserted that exact ExecStart line and had pinned it in place.

Push endpoints skipped check_url, the only outbound request that did. And
a chat could be filed in another account's folder, which hands over its
system prompt -- `_new_chat` resolved the folder, discarded it when it was
not the caller's, and stored the raw id anyway.

An existing helper install keeps the old wiring until install.sh is
re-run; update.sh now says so when it finds itself inside the checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:45:59 +02:00
Jaroslav Beneš 32a3b54f1f An update script that stopped where nobody could see it
Found by running it rather than by reading it. Under `set -euo pipefail` the tag
resolution added in the last commit dies when no release tag exists -- grep exits
1 when nothing matches, and `head -1` closing the pipe early can hand it a
SIGPIPE besides. That is every host until the first release is tagged, which is
every host today. It printed "== fetching ==" and stopped: fetched, not reset,
not restarted, and exit status swallowed by the pipe it was being read through.
The fallback comment two lines above claimed to handle exactly this case.

And the consequence of moving to SSH: install.sh takes REPO_URL from the running
checkout's origin, so whoever pushes over SSH now hands the deployment a URL the
service user cannot use -- it has no key and should not have one, being a
credential that can push to the repository sitting on a box to do a read-only
job. The clone would have failed loudly, with "Permission denied (publickey)"
from an account nobody was thinking about. It is refused up front with the fix
named instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:00:27 +02:00
Jaroslav Beneš 0c438a6333 A version somebody can read, instead of a sha nobody can
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>
2026-08-06 20:42:03 +02:00
Jaroslav Beneš 621e95d2e3 Find the vhost by what it proxies to when .deploy-env is absent
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>
2026-08-02 01:47:47 +02:00
Jaroslav Beneš 5117168454 A terminal panel beside an agent chat
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>
2026-08-02 01:44:07 +02:00
Jaroslav Beneš 191394fa08 Deploy the ssh extra, from one place
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>
2026-08-01 22:36:48 +02:00
Jaroslav Beneš 8c3fe97939 Say when the systemd unit has moved on
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>
2026-08-01 19:35:42 +02:00
Jaroslav Beneš 7456525d19 PWA, one send/stop button, audio in and out, web search as a tool
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>
2026-07-21 17:56:50 +02:00
Jaroslav Beneš ba2fb1e13d Add registration toggle and password change; genericise deploy
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>
2026-07-21 11:14:33 +02:00
Jaroslav Beneš dd9e0e9440 Working chat: auth, connections, streaming, folders
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>
2026-07-21 11:04:13 +02:00