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 c3d6660881
commit fe7227af62
21 changed files with 2452 additions and 7 deletions
+183
View File
@@ -0,0 +1,183 @@
{% extends "agents/_layout.html" %}
{% from "_macros.html" import icon %}
{% block title %}{{ "New connection" if is_new else profile.name }} - LLeMbas{% endblock %}
{% block heading %}{{ "New connection" if is_new else profile.name }}{% endblock %}
{% block agents_content %}
<nav class="crumbs">
<a class="crumbs__back" href="/agents">
{{ icon("chevron-right", "icon--sm crumbs__icon") }} All connections
</a>
</nav>
{% if error %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
{% endif %}
{% if problem %}
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ problem }}</span></div>
{% endif %}
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
{% if not is_new %}
<section class="card">
<h2 class="card__title">Check it</h2>
<p class="field__hint">
{% if profile.verified %}
Confirms the host is still the one you accepted, then logs in and runs
something harmless to see that it works.
{% else %}
Looks at this host's key and shows you its fingerprint. Nothing is sent to
it — not your username, not your credential — until you accept.
{% endif %}
</p>
<div class="btn-row">
<button class="btn" type="button"
hx-post="/api/agents/{{ profile.id }}/check"
hx-target="#check-result">
{{ icon("refresh", "icon--sm") }} Check
</button>
</div>
<div id="check-result">
{% if profile.verified %}
<p class="field__hint">
Accepted fingerprint: <code>{{ profile.host_fingerprint }}</code>
</p>
{% endif %}
</div>
</section>
{% endif %}
<form method="post" class="form-grid"
action="{{ '/api/agents' if is_new else '/api/agents/' ~ profile.id }}">
<section class="card">
<h2 class="card__title">The machine</h2>
<div class="field">
<label class="field__label" for="name">Name</label>
<input class="input" id="name" name="name" value="{{ profile.name }}" required
maxlength="120" placeholder="Project container">
<p class="field__hint">What you will pick from when starting an agent chat.</p>
</div>
<div class="field">
<label class="field__label" for="host">Host</label>
<input class="input input--mono" id="host" name="host" value="{{ profile.host }}" required
maxlength="255" placeholder="127.0.0.1">
</div>
<div class="field">
<label class="field__label" for="port">Port</label>
<input class="input" id="port" name="port" value="{{ profile.port }}" inputmode="numeric">
</div>
<div class="field">
<label class="field__label" for="username">Log in as</label>
<input class="input input--mono" id="username" name="username" required
value="{{ profile.username }}" maxlength="120" placeholder="root">
</div>
<div class="field">
<label class="field__label" for="default_dir">Project directory</label>
<input class="input input--mono" id="default_dir" name="default_dir"
value="{{ profile.default_dir }}" maxlength="500" placeholder="/project">
<p class="field__hint">
Where a chat starts by default. Each chat records its own when it is
created, so changing this later does not move a conversation already
under way.
</p>
</div>
<div class="field">
<label class="field__label" for="connect_timeout">Connect timeout (seconds)</label>
<input class="input" id="connect_timeout" name="connect_timeout"
value="{{ profile.connect_timeout }}" inputmode="numeric">
</div>
</section>
<section class="card">
<h2 class="card__title">How it logs in</h2>
<div class="field">
<div class="checkbox-row">
<label class="checkbox">
<input type="radio" name="auth" value="key"
{{ 'checked' if profile.auth != 'password' }}>
<span>A private key</span>
</label>
<label class="checkbox">
<input type="radio" name="auth" value="password"
{{ 'checked' if profile.auth == 'password' }}>
<span>A password</span>
</label>
</div>
</div>
<div class="field">
<label class="field__label" for="private_key">Private key</label>
<textarea class="textarea input--mono" id="private_key" name="private_key" rows="5"
spellcheck="false"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
>{{ unchanged if has_key else '' }}</textarea>
<p class="field__hint">
{% if has_key %}
A key is saved. Leave the dots alone to keep it, or clear the box to
remove it.
{% else %}
Pasted whole, encrypted at rest, and never shown again.
{% endif %}
</p>
</div>
<div class="field">
<label class="field__label" for="key_passphrase">Key passphrase</label>
<input class="input input--mono" id="key_passphrase" name="key_passphrase"
type="password" autocomplete="off" placeholder="If the key has one">
</div>
<div class="field">
<label class="field__label" for="password">Password</label>
<input class="input input--mono" id="password" name="password" type="password"
autocomplete="off" placeholder="No password set"
value="{{ unchanged if profile.password_encrypted else '' }}">
<p class="field__hint">
{% if profile.password_encrypted %}
Currently <code>{{ masked_password }}</code>. Leave the dots alone to
keep it, or clear the field to remove it.
{% else %}
Only used when this connection logs in with a password.
{% endif %}
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Availability</h2>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if profile.enabled }}>
<span>Enabled — can be picked when starting an agent chat</span>
</label>
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">
{{ "Add connection" if is_new else "Save changes" }}
</button>
<a class="btn btn--ghost" href="/agents">Back to all connections</a>
{% if not is_new %}
<button class="btn btn--danger" type="submit" formnovalidate
formaction="/api/agents/{{ profile.id }}/delete"
data-confirm-button="Delete the connection “{{ profile.name }}”? Chats that used it keep their transcripts.">
Delete
</button>
{% endif %}
</div>
</form>
{% endblock %}