SSH connections, kept by the people who own them

An agent chat will act on a machine you choose, so this is the screen where
you choose it. User-owned like a note, not admin-owned like a connection:
these are somebody's own machines and somebody's own keys, and "anyone in
this group may log in to my server" is a different feature with a different
blast radius. services/sharing.py is deliberately not involved either --
sharing grants reading, and a host somebody else can read is a host they
can log in to.

Trust on first use, made explicit rather than assumed. Adding a host does
not connect to it. Check looks at its key and shows you the fingerprint;
nothing is sent until you accept, because get_server_host_key completes the
key exchange and stops -- no username, no credential. Accepting pins it,
and a host that later presents a different key is refused with the reason
rather than quietly trusted. Moving a profile to another host or port
forgets the pin, since a key belongs to the machine it came from.

Four asyncssh defaults are actively wrong here and all four are passed
explicitly: every LLeMbas user shares one unix account, so `known_hosts`
would be a shared trust store, `client_keys` would authenticate one person
with another's key, `config` would let a ProxyCommand redirect the
connection, and `agent_path` would silently use $SSH_AUTH_SOCK. There is a
test for exactly that, and it needs no server.

Files go over SFTP rather than through a shell. The SSH exec protocol
carries one command *string* that the far side parses, with no argv form at
all, so a model-supplied path in a command line is unavoidably a quoting
problem. Over SFTP a path is a path.

Chat gains its kind, connection, project directory and mode; the first
three are fixed once a chat has a message, because a transcript whose
earlier turns ran somewhere else is not one conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 22:34:58 +02:00
co-authored by Claude Opus 5
parent 0a4531f02d
commit 4ced049ff8
21 changed files with 2452 additions and 7 deletions
@@ -0,0 +1,94 @@
{% extends "agents/_layout.html" %}
{% from "_macros.html" import icon %}
{% block title %}Connections - LLeMbas{% endblock %}
{% block heading %}Connections{% endblock %}
{% block actions %}
<a class="btn btn--primary btn--sm" href="/agents/new">
{{ icon("plus", "icon--sm") }} Add a connection
</a>
{% endblock %}
{% block agents_content %}
<p class="admin-lede">
Machines an <strong>Agent</strong> chat can work on. A model with one of these
can read files, write files and run commands <em>there</em> — never here.
Which of those it may do without asking you first is the chat's mode.
</p>
<div class="alert">
{{ icon("shield", "icon--sm") }}
<span>
Whatever this connection can reach, a model in an agent chat can reach. A
container built for the job, with one project mounted into it, is a very
different thing from a key to a machine you care about — and LLeMbas cannot
tell them apart.
</span>
</div>
{% if problem %}
<div class="alert alert--error">
{{ icon("warning", "icon--sm") }} <span>{{ problem }}</span>
</div>
{% elif not enabled %}
<div class="alert alert--error">
{{ icon("warning", "icon--sm") }}
<span>
Agent chats are switched off for this instance. You can still add
connections here, but nothing will use them until an administrator turns
them on.
</span>
</div>
{% endif %}
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
{% if not profiles %}
<div class="empty">
{{ icon("server", "empty__mark") }}
<h2 class="empty__title">No connections yet</h2>
<p class="empty__text">
Add the host, the user to log in as, and a key or password. Then press
<strong>Check</strong> — you will be shown its fingerprint to confirm before
anything is sent to it.
</p>
<a class="btn btn--primary" href="/agents/new">
{{ icon("plus", "icon--sm") }} Add a connection
</a>
</div>
{% else %}
<div class="model-rows">
{% for profile in profiles %}
<div class="model-row {{ 'is-off' if not profile.enabled }}">
<div class="model-row__main">
<div class="model-row__title">
<a class="model-row__name" href="/agents/{{ profile.id }}">{{ profile.name }}</a>
{% if profile.verified %}
<span class="badge badge--leaf">key confirmed</span>
{% else %}
<span class="badge badge--danger">not checked</span>
{% endif %}
{% if not profile.enabled %}<span class="badge">disabled</span>{% endif %}
{% if profile.auth == "password" %}<span class="badge">password</span>{% endif %}
</div>
<code class="model-row__id">
{{ profile.address }}{% if profile.default_dir %} · {{ profile.default_dir }}{% endif %}
</code>
{% if profile.last_error %}
<p class="text-xs danger">{{ profile.last_error }}</p>
{% elif profile.server_info.system %}
<p class="text-xs faint">{{ profile.server_info.system }}</p>
{% endif %}
</div>
<div class="model-row__actions">
<a class="btn btn--sm" href="/agents/{{ profile.id }}">Open</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}