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:
@@ -471,3 +471,9 @@ a.tabs__tab { text-decoration: none; }
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
}
|
||||
|
||||
/* --- The permission modes, explained on the agents page ------------------- */
|
||||
.mode-list { margin: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.mode-list__row { display: flex; gap: var(--sp-3); align-items: baseline; }
|
||||
.mode-list__row dt { flex: 0 0 5rem; color: var(--ink); }
|
||||
.mode-list__row dd { margin: 0; color: var(--ink-muted); font-size: var(--text-sm); }
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
{{ icon("link", "icon--sm") }}
|
||||
<span class="nav-item__label">Tools</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'agents' }}" href="/admin/agents">
|
||||
{{ icon("sparkle", "icon--sm") }}
|
||||
<span class="nav-item__label">Agents</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'mcp' }}" href="/admin/mcp">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
<span class="nav-item__label">MCP servers</span>
|
||||
@@ -70,13 +74,6 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Not yet built</div>
|
||||
<span class="nav-item is-disabled">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
<span class="nav-item__label">Agents</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "agents" %}
|
||||
|
||||
{% block title %}Agents - LLeMbas{% endblock %}
|
||||
{% block heading %}Agents{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
An <strong>Agent</strong> chat can read files, write files and run commands on
|
||||
a machine reached over SSH. Nothing runs on this server. People add their own
|
||||
connections under <strong>Connections</strong>; what you decide here is
|
||||
whether the feature exists and what one reply may spend.
|
||||
</p>
|
||||
|
||||
<div class="alert">
|
||||
{{ icon("shield", "icon--sm") }}
|
||||
<span>
|
||||
There is no sandbox to configure, and that is deliberate: containment is
|
||||
whatever host somebody points a connection at. A container built for the
|
||||
job is a very different thing from a key to a live server, and LLeMbas
|
||||
cannot tell them apart. What a model reads — a web page, a file, the output
|
||||
of the last command — is untrusted, and in <strong>Auto</strong> mode
|
||||
nothing stands between that and a command running.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<form method="post" action="/admin/agents" class="form-grid">
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Switch</h2>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
|
||||
<span>Allow agent chats</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off, nobody can start one and no agent tool is offered, whatever
|
||||
permissions they hold. {{ profile_count }} connection{{ '' if profile_count == 1 else 's' }}
|
||||
saved across all accounts.
|
||||
</p>
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
People also need the <strong>Run commands</strong> permission, a model
|
||||
flagged <strong>Agent execution</strong>, and a connection of their own.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">The modes</h2>
|
||||
<p class="field__hint">
|
||||
Set per chat and switchable at any time. This is what each one means; the
|
||||
two lists below adjust them.
|
||||
</p>
|
||||
<dl class="mode-list">
|
||||
{% for value, label, hint in modes %}
|
||||
<div class="mode-list__row">
|
||||
<dt><strong>{{ label }}</strong></dt>
|
||||
<dd>{{ hint }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What never needs asking</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="allow_default">Always allow</label>
|
||||
<textarea class="textarea input--mono" id="allow_default" name="allow_default" rows="5"
|
||||
spellcheck="false">{{ allow_text }}</textarea>
|
||||
<p class="field__hint">
|
||||
One per line: a tool name like <code>file_read</code>, or a command with
|
||||
wildcards like <code>git *</code>. A command containing anything that
|
||||
joins two commands together — a semicolon, a pipe, backticks — can never
|
||||
match one of these, so <code>git *</code> does not quietly also mean
|
||||
<code>git status; curl … | sh</code>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What always needs asking</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="deny_default">Always ask</label>
|
||||
<textarea class="textarea input--mono" id="deny_default" name="deny_default" rows="5"
|
||||
spellcheck="false">{{ deny_text }}</textarea>
|
||||
<p class="field__hint">
|
||||
Checked before everything, including <strong>Auto</strong>. Treat it as
|
||||
a guard against an accident rather than against an adversary:
|
||||
<code>rm -rf /*</code> here does not stop <code>/bin/rm -rf /</code>, and
|
||||
nothing pattern-shaped could.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What one command may spend</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="default_timeout">Timeout (seconds)</label>
|
||||
<input class="input" id="default_timeout" name="default_timeout"
|
||||
value="{{ values.default_timeout }}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_timeout">Longest a command may ask for</label>
|
||||
<input class="input" id="max_timeout" name="max_timeout"
|
||||
value="{{ values.max_timeout }}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_output_bytes">Most output to keep</label>
|
||||
<input class="input" id="max_output_bytes" name="max_output_bytes"
|
||||
value="{{ values.max_output_bytes }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
Characters. The rest is cut off and the model is told so.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">What one reply may spend</h2>
|
||||
<p class="field__hint">
|
||||
Three separate bounds, because they fail differently: steps stop a loop,
|
||||
the clock stops one slow command eating an afternoon, and output stops a
|
||||
model filling its own context with build logs and having no room to answer.
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_steps">Most rounds of tool calls</label>
|
||||
<input class="input" id="max_steps" name="max_steps"
|
||||
value="{{ values.max_steps }}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_wall_seconds">Longest a reply may take</label>
|
||||
<input class="input" id="max_wall_seconds" name="max_wall_seconds"
|
||||
value="{{ values.max_wall_seconds }}" inputmode="numeric">
|
||||
<p class="field__hint">Time spent waiting for you to answer does not count.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="max_total_output_bytes">Most output across a reply</label>
|
||||
<input class="input" id="max_total_output_bytes" name="max_total_output_bytes"
|
||||
value="{{ values.max_total_output_bytes }}" inputmode="numeric">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Asking you things</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="approval_timeout">How long a question waits</label>
|
||||
<input class="input" id="approval_timeout" name="approval_timeout"
|
||||
value="{{ values.approval_timeout }}" inputmode="numeric">
|
||||
<p class="field__hint">
|
||||
Seconds. After this the reply carries on without an answer and says so.
|
||||
At least a minute, whatever is typed here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="ask_free_text" value="true"
|
||||
{{ 'checked' if values.ask_free_text }}>
|
||||
<span>Let people write their own answer</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
When a model asks a question it can offer answers to pick from, and by
|
||||
default a box to write something else. Turn this off if you would rather
|
||||
nobody typed free text into a prompt a model composed.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
What looking at a host found.
|
||||
|
||||
Everything here came from the far side and is escaped accordingly. The
|
||||
fingerprint especially: it is the one string a person is being asked to
|
||||
compare against something they know, so it is shown plainly and never
|
||||
reformatted.
|
||||
#}
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ error }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
{% if offer %}
|
||||
<div class="card" style="margin-top: var(--sp-4)">
|
||||
<h3 class="card__title">
|
||||
{{ "This host's key has changed" if offer.changed else "Is this the machine you meant?" }}
|
||||
</h3>
|
||||
<p class="field__hint">
|
||||
{% if offer.changed %}
|
||||
Accept only if you know why it changed — a rebuilt container will do this,
|
||||
and so will something pretending to be your machine.
|
||||
{% else %}
|
||||
Compare this with what the host reports for itself. On the machine, that
|
||||
is <code>ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub</code>.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<pre class="tool-result__text">{{ offer.fingerprint }}</pre>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="button"
|
||||
hx-post="/api/agents/{{ profile.id }}/accept"
|
||||
hx-target="#check-result"
|
||||
hx-confirm="Accept this fingerprint and pin it? Every future connection will be checked against it."
|
||||
data-confirm-label="Accept"
|
||||
data-confirm-danger="{{ 'true' if offer.changed else 'false' }}">
|
||||
{{ icon("check", "icon--sm") }} Accept and pin
|
||||
</button>
|
||||
{% if profile.verified %}
|
||||
<button class="btn" type="button"
|
||||
hx-post="/api/agents/{{ profile.id }}/forget"
|
||||
hx-target="#check-result">
|
||||
Forget the old key
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if accepted %}
|
||||
<div class="alert alert--success">
|
||||
{{ icon("check", "icon--sm") }}
|
||||
<span>Pinned <code>{{ accepted }}</code>. Check again to log in.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if forgotten %}
|
||||
<div class="alert">
|
||||
{{ icon("warning", "icon--sm") }}
|
||||
<span>Key forgotten. Check again to see the new one.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if found %}
|
||||
<div class="alert alert--success">
|
||||
{{ icon("check", "icon--sm") }}
|
||||
<span>Connected{% if found.system %} to {{ found.system }}{% endif %}.</span>
|
||||
</div>
|
||||
<p class="field__hint">What it said:</p>
|
||||
<pre class="tool-result__text">{{ found.output }}</pre>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
Connections, kept by the person who owns them.
|
||||
|
||||
Shares the chat sidebar with the library for the same reason: this is part of
|
||||
using LLeMbas, not administering it. You come here to add a machine and go
|
||||
straight back to a conversation.
|
||||
#}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<h1 class="topbar__title">{% block heading %}Connections{% endblock %}</h1>
|
||||
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-scroll">
|
||||
<div class="admin-page">
|
||||
{% block agents_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -77,6 +77,13 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can.get("agent.ssh") %}
|
||||
<a class="nav-item" href="/agents">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
<span class="nav-item__label">Connections</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a class="nav-item" href="/settings">
|
||||
{{ icon("user", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ user.name }}</span>
|
||||
|
||||
Reference in New Issue
Block a user