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:
@@ -49,6 +49,12 @@ dev = [
|
||||
# is already a core dependency. Without this the provider is offered in the
|
||||
# admin UI with an install hint rather than silently missing.
|
||||
search = ["ddgs>=9.0"]
|
||||
# Agent chats, which run their commands on a machine reached over SSH. Optional
|
||||
# on the same terms as `search`: an instance that never turns agents on should
|
||||
# not carry the dependency, and one that does gets told how to install it rather
|
||||
# than finding the feature silently missing. `bcrypt` is what decrypts a
|
||||
# passphrase-protected OpenSSH key -- without it, pasting one fails opaquely.
|
||||
ssh = ["asyncssh[bcrypt]>=2.14"]
|
||||
|
||||
[project.scripts]
|
||||
lembas = "lembas.cli:app"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Whether agent chats exist here at all, and what they may spend.
|
||||
|
||||
An administrator's half of the feature. The other half -- which machines, whose
|
||||
credentials -- belongs to whoever owns them and lives at `/agents`.
|
||||
|
||||
Nothing here is about isolation, because there is none to configure: commands
|
||||
run on a host somebody chose, and its containment is that host's. The settings
|
||||
are budgets, and the two lists that decide what a mode asks about.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/agents", tags=["admin-agents"])
|
||||
|
||||
|
||||
def _lines(text: str) -> list[str]:
|
||||
"""One pattern per line, blanks dropped."""
|
||||
return [line.strip() for line in (text or "").splitlines() if line.strip()]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
values = settings_store.agents(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/agents.html",
|
||||
{
|
||||
"values": values,
|
||||
"allow_text": "\n".join(values.get("allow_default") or []),
|
||||
"deny_text": "\n".join(values.get("deny_default") or []),
|
||||
"problem": ssh_service.available(),
|
||||
"profile_count": db.scalar(select(func.count()).select_from(SshProfile)) or 0,
|
||||
"modes": [(m, policy.MODE_LABELS[m], policy.MODE_HINTS[m]) for m in policy.MODES],
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_agents(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
default_timeout: int = Form(60),
|
||||
max_timeout: int = Form(600),
|
||||
max_output_bytes: int = Form(64 * 1024),
|
||||
max_steps: int = Form(40),
|
||||
max_wall_seconds: int = Form(900),
|
||||
max_total_output_bytes: int = Form(1024 * 1024),
|
||||
approval_timeout: int = Form(900),
|
||||
allow_default: str = Form(""),
|
||||
deny_default: str = Form(""),
|
||||
ask_free_text: bool = Form(False),
|
||||
) -> Response:
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Clamped here as well as on read. A number with no bound is a way
|
||||
# to break the instance from a form, which is the same reasoning
|
||||
# the search settings carry.
|
||||
"default_timeout": min(max(default_timeout, 1), 3600),
|
||||
"max_timeout": min(max(max_timeout, 1), 3600),
|
||||
"max_output_bytes": min(max(max_output_bytes, 1024), 1024 * 1024),
|
||||
"max_steps": min(max(max_steps, 1), 200),
|
||||
"max_wall_seconds": min(max(max_wall_seconds, 30), 7200),
|
||||
"max_total_output_bytes": min(max(max_total_output_bytes, 4096), 8 * 1024 * 1024),
|
||||
"approval_timeout": min(max(approval_timeout, 60), 3600),
|
||||
"allow_default": _lines(allow_default),
|
||||
"deny_default": _lines(deny_default),
|
||||
"ask_free_text": ask_free_text,
|
||||
},
|
||||
key=settings_store.AGENTS,
|
||||
)
|
||||
log.info("agent execution %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -42,6 +42,7 @@ TOOL_CAPABILITIES = (
|
||||
("tool_custom", "Custom tools"),
|
||||
("tool_mcp", "MCP servers"),
|
||||
("tool_ask", "Ask the reader"),
|
||||
("tool_agent", "Agent execution"),
|
||||
)
|
||||
|
||||
CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES)
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""SSH connections, kept by the people who own them.
|
||||
|
||||
Not an admin screen. These are somebody's own machines and somebody's own keys,
|
||||
so the pages sit beside the library rather than under `/admin` -- an
|
||||
administrator decides only whether the feature exists at all.
|
||||
|
||||
Trust on first use, made explicit. Adding a host does not connect to it; the
|
||||
**Check** button looks at its key, shows the fingerprint, and waits. Only when
|
||||
that is accepted is the key pinned, and only then will anything authenticate.
|
||||
`asyncssh.get_server_host_key` completes the key exchange and stops, so a host
|
||||
that has not been accepted is never offered a username, let alone a credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent.base import ExecError
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(require_permission("agent.ssh"))], tags=["agents"]
|
||||
)
|
||||
|
||||
|
||||
def _profile(db: Db, user: RequiredUser, profile_id: str) -> SshProfile:
|
||||
"""One profile belonging to this person.
|
||||
|
||||
Ownership is the whole authorisation. `sharing.py` is deliberately not
|
||||
involved: it grants reading, and a host somebody else can read is a host
|
||||
they can log in to.
|
||||
"""
|
||||
profile = db.get(SshProfile, profile_id)
|
||||
if profile is None or profile.owner_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
|
||||
return profile
|
||||
|
||||
|
||||
def _owned(db: Db, user_id: str) -> list[SshProfile]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(SshProfile).where(SshProfile.owner_id == user_id).order_by(SshProfile.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _back(message: str = "") -> Response:
|
||||
target = f"/agents?saved={message}" if message else "/agents"
|
||||
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
def _number(raw, *, default: int, low: int, high: int) -> int:
|
||||
text = str(raw or "").strip()
|
||||
if not text.isdigit():
|
||||
return default
|
||||
return min(max(int(text), low), high)
|
||||
|
||||
|
||||
def _apply(profile: SshProfile, form) -> None:
|
||||
"""Copy a submitted form onto a profile.
|
||||
|
||||
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
|
||||
`x`, and an absent one is exactly what an unticked box sends.
|
||||
"""
|
||||
profile.name = str(form.get("name") or "").strip()[:120]
|
||||
profile.host = str(form.get("host") or "").strip()[:255]
|
||||
profile.username = str(form.get("username") or "").strip()[:120]
|
||||
profile.port = _number(form.get("port"), default=22, low=1, high=65535)
|
||||
profile.connect_timeout = _number(form.get("connect_timeout"), default=15, low=3, high=120)
|
||||
profile.default_dir = str(form.get("default_dir") or "").strip()[:500]
|
||||
|
||||
method = str(form.get("auth") or "").strip()
|
||||
profile.auth = method if method in AUTH_METHODS else profile.auth
|
||||
profile.enabled = "enabled" in form
|
||||
|
||||
|
||||
def _detail(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
profile: SshProfile,
|
||||
*,
|
||||
is_new: bool,
|
||||
error: str = "",
|
||||
saved: str = "",
|
||||
):
|
||||
# The user is passed rather than read off the profile: a draft has never
|
||||
# been attached to a session, so `profile.owner` is None on the one page
|
||||
# that most needs a sidebar.
|
||||
return render(
|
||||
request,
|
||||
"agents/detail.html",
|
||||
{
|
||||
**sidebar_context(db, user),
|
||||
"profile": profile,
|
||||
"is_new": is_new,
|
||||
"error": error,
|
||||
"saved": saved,
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"masked_password": mask(decrypt(profile.password_encrypted))
|
||||
if profile.password_encrypted
|
||||
else "",
|
||||
"has_key": bool(profile.private_key_encrypted),
|
||||
"problem": ssh_service.available(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def agents_page(request: Request, db: Db, user: RequiredUser, saved: str = ""):
|
||||
return render(
|
||||
request,
|
||||
"agents/index.html",
|
||||
{
|
||||
**sidebar_context(db, user),
|
||||
"profiles": _owned(db, user.id),
|
||||
"saved": saved,
|
||||
"problem": ssh_service.available(),
|
||||
"enabled": bool(settings_store.agents(db).get("enabled")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Registered before /{profile_id}: FastAPI matches in registration order, so
|
||||
# with the parameterised route first "new" is captured as an id. This has been
|
||||
# a bug once already, in /admin/models.
|
||||
@router.get("/agents/new")
|
||||
async def new_profile_page(request: Request, db: Db, user: RequiredUser):
|
||||
draft = SshProfile(
|
||||
owner_id=user.id, name="", host="", username="", port=22, connect_timeout=15, enabled=True
|
||||
)
|
||||
return _detail(request, db, user, draft, is_new=True)
|
||||
|
||||
|
||||
@router.post("/api/agents")
|
||||
async def create_profile(request: Request, db: Db, user: RequiredUser) -> Response:
|
||||
form = await request.form()
|
||||
profile = SshProfile(owner_id=user.id)
|
||||
_apply(profile, form)
|
||||
|
||||
if problem := _problem(db, profile, user.id):
|
||||
return _detail(request, db, user, profile, is_new=True, error=problem)
|
||||
|
||||
profile.password_encrypted = keep_or_replace(str(form.get("password") or ""), "")
|
||||
profile.private_key_encrypted = keep_or_replace(str(form.get("private_key") or ""), "")
|
||||
profile.key_passphrase_encrypted = keep_or_replace(str(form.get("key_passphrase") or ""), "")
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
|
||||
log.info("%s added ssh profile %s", user.email, profile.name)
|
||||
return RedirectResponse(
|
||||
f"/agents/{profile.id}?saved=Added+{profile.name}.+Check+it+to+confirm+its+fingerprint.",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
|
||||
def _problem(db: Db, profile: SshProfile, owner_id: str, *, existing_id: str = "") -> str:
|
||||
if not profile.name:
|
||||
return "A connection needs a name."
|
||||
if not profile.host:
|
||||
return "A connection needs a host."
|
||||
if not profile.username:
|
||||
return "A connection needs a username to log in as."
|
||||
|
||||
clash = db.scalar(
|
||||
select(SshProfile).where(
|
||||
SshProfile.owner_id == owner_id, SshProfile.name == profile.name
|
||||
)
|
||||
)
|
||||
if clash is not None and clash.id != existing_id:
|
||||
return f"You already have a connection called “{profile.name}”."
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/agents/{profile_id}")
|
||||
async def profile_page(
|
||||
request: Request, db: Db, user: RequiredUser, profile_id: str, saved: str = ""
|
||||
):
|
||||
profile = _profile(db, user, profile_id)
|
||||
return _detail(request, db, user, profile, is_new=False, saved=saved)
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/check")
|
||||
async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
"""Look at the host's key, and connect if it has already been accepted.
|
||||
|
||||
Two steps in one button, because they are one question: *is this the machine
|
||||
I meant, and will it let me in?* An unseen key comes back as a fingerprint
|
||||
to accept; an accepted one is used to log in and run something harmless.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
|
||||
try:
|
||||
line, fingerprint = await ssh_service.capture_host_key(
|
||||
profile.host, profile.port, timeout=profile.connect_timeout
|
||||
)
|
||||
except ExecError as exc:
|
||||
profile.last_error = exc.message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request, "agents/_check.html", {"profile": profile, "error": exc.message}
|
||||
)
|
||||
|
||||
if not profile.host_key:
|
||||
# First sight. Nothing is pinned until a person says so.
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{"profile": profile, "offer": {"line": line, "fingerprint": fingerprint}},
|
||||
)
|
||||
|
||||
if line.strip() != profile.host_key.strip():
|
||||
message = (
|
||||
"This host is presenting a different key than the one you accepted. "
|
||||
"Nothing was sent to it. If you rebuilt the machine, forget the key "
|
||||
"below and check again; if you did not, stop and find out why."
|
||||
)
|
||||
profile.last_error = message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{
|
||||
"profile": profile,
|
||||
"error": message,
|
||||
"offer": {"line": line, "fingerprint": fingerprint, "changed": True},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
found = await ssh_service.check(ssh_service.spec_from(profile), profile.default_dir)
|
||||
except ExecError as exc:
|
||||
profile.last_error = exc.message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request, "agents/_check.html", {"profile": profile, "error": exc.message}
|
||||
)
|
||||
|
||||
profile.last_error = ""
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
profile.server_info = {"system": found.get("system", ""), "cwd": found.get("cwd", "")}
|
||||
db.commit()
|
||||
return render(request, "agents/_check.html", {"profile": profile, "found": found})
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/accept")
|
||||
async def accept_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
"""Pin the fingerprint that was just shown.
|
||||
|
||||
The line is re-fetched rather than taken from the form: a value that made a
|
||||
round trip through a browser is not what should end up as the thing every
|
||||
future connection is checked against.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
try:
|
||||
line, fingerprint = await ssh_service.capture_host_key(
|
||||
profile.host, profile.port, timeout=profile.connect_timeout
|
||||
)
|
||||
except ExecError as exc:
|
||||
return render(request, "agents/_check.html", {"profile": profile, "error": exc.message})
|
||||
|
||||
profile.host_key = line
|
||||
profile.host_fingerprint = fingerprint
|
||||
profile.last_error = ""
|
||||
db.commit()
|
||||
log.info("%s pinned host key for %s (%s)", user.email, profile.name, fingerprint)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{"profile": profile, "accepted": fingerprint},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/forget")
|
||||
async def forget_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
profile = _profile(db, user, profile_id)
|
||||
profile.host_key = ""
|
||||
profile.host_fingerprint = ""
|
||||
db.commit()
|
||||
return render(request, "agents/_check.html", {"profile": profile, "forgotten": True})
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/delete")
|
||||
async def delete_profile(db: Db, user: RequiredUser, profile_id: str) -> Response:
|
||||
profile = _profile(db, user, profile_id)
|
||||
name = profile.name
|
||||
db.delete(profile)
|
||||
db.commit()
|
||||
log.info("%s deleted ssh profile %s", user.email, name)
|
||||
return _back(f"Deleted {name}.")
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}")
|
||||
async def update_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
profile = _profile(db, user, profile_id)
|
||||
form = await request.form()
|
||||
|
||||
before = (profile.host, profile.port)
|
||||
_apply(profile, form)
|
||||
|
||||
if problem := _problem(db, profile, user.id, existing_id=profile.id):
|
||||
db.rollback()
|
||||
return _detail(
|
||||
request, db, user, _profile(db, user, profile_id), is_new=False, error=problem
|
||||
)
|
||||
|
||||
profile.password_encrypted = keep_or_replace(
|
||||
str(form.get("password") or ""), profile.password_encrypted
|
||||
)
|
||||
profile.private_key_encrypted = keep_or_replace(
|
||||
str(form.get("private_key") or ""), profile.private_key_encrypted
|
||||
)
|
||||
profile.key_passphrase_encrypted = keep_or_replace(
|
||||
str(form.get("key_passphrase") or ""), profile.key_passphrase_encrypted
|
||||
)
|
||||
if profile.auth == AUTH_PASSWORD:
|
||||
profile.private_key_encrypted = ""
|
||||
profile.key_passphrase_encrypted = ""
|
||||
|
||||
# A pinned key belongs to a host and a port. Moving either means this is a
|
||||
# different machine until proven otherwise, and silently keeping the old
|
||||
# key would be the one mistake this whole mechanism exists to prevent.
|
||||
if (profile.host, profile.port) != before and profile.host_key:
|
||||
profile.host_key = ""
|
||||
profile.host_fingerprint = ""
|
||||
log.info("%s moved ssh profile %s; its host key was forgotten", user.email, profile.name)
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/agents/{profile.id}?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
@@ -5,6 +5,12 @@ what ``init_db()`` relies on to create the schema at startup. Any new model
|
||||
module must be imported here or its table will silently never be created.
|
||||
"""
|
||||
|
||||
from lembas.db.models.agent import (
|
||||
AUTH_KEY,
|
||||
AUTH_METHODS,
|
||||
AUTH_PASSWORD,
|
||||
SshProfile,
|
||||
)
|
||||
from lembas.db.models.attachment import (
|
||||
KIND_DOCUMENT,
|
||||
KIND_IMAGE,
|
||||
@@ -12,6 +18,9 @@ from lembas.db.models.attachment import (
|
||||
Attachment,
|
||||
)
|
||||
from lembas.db.models.chat import (
|
||||
KIND_AGENT,
|
||||
KIND_CHAT,
|
||||
KINDS,
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_SYSTEM,
|
||||
ROLE_TOOL,
|
||||
@@ -68,8 +77,14 @@ from lembas.db.models.user import (
|
||||
|
||||
__all__ = [
|
||||
"AUTHOR_MODEL",
|
||||
"AUTH_KEY",
|
||||
"AUTH_METHODS",
|
||||
"AUTH_PASSWORD",
|
||||
"AUTHOR_USER",
|
||||
"Attachment",
|
||||
"KINDS",
|
||||
"KIND_AGENT",
|
||||
"KIND_CHAT",
|
||||
"KIND_DOCUMENT",
|
||||
"KIND_IMAGE",
|
||||
"KIND_TEXT",
|
||||
@@ -111,6 +126,7 @@ __all__ = [
|
||||
"Setting",
|
||||
"Share",
|
||||
"Skill",
|
||||
"SshProfile",
|
||||
"SkillRevision",
|
||||
"Suggestion",
|
||||
"User",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""SSH connections an agent chat can act through.
|
||||
|
||||
User-owned, like a `Note` and unlike a `Connection`. That is the opposite of
|
||||
the rule custom tools and MCP servers follow, and the difference is the point:
|
||||
those are instance configuration an administrator could grant themselves in one
|
||||
click anyway, while this is somebody's own machine and somebody's own key.
|
||||
"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.
|
||||
|
||||
**Nothing an agent does runs on the LLeMbas machine.** A local sandbox was
|
||||
designed and dropped: every hard problem in it came from executing on the host
|
||||
that holds the database and the encryption key. Over SSH, isolation is whatever
|
||||
host somebody points this at -- which means the security of an agent chat is the
|
||||
security of that host, and nothing here can tell a throwaway container from a
|
||||
production server. The admin copy says so out loud.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - annotation only
|
||||
from lembas.db.models.user import User
|
||||
|
||||
# How the connection authenticates.
|
||||
AUTH_KEY = "key"
|
||||
AUTH_PASSWORD = "password"
|
||||
AUTH_METHODS = (AUTH_KEY, AUTH_PASSWORD)
|
||||
|
||||
|
||||
class SshProfile(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One host somebody can point an agent chat at."""
|
||||
|
||||
__tablename__ = "ssh_profiles"
|
||||
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_ssh_profile_name"),)
|
||||
|
||||
owner_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, default=22, nullable=False)
|
||||
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
|
||||
auth: Mapped[str] = mapped_column(String(16), default=AUTH_KEY, nullable=False)
|
||||
password_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
private_key_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
key_passphrase_encrypted: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
# One OpenSSH known_hosts line, captured the first time this host answered
|
||||
# and shown as a fingerprint to be confirmed, then pinned. Empty means
|
||||
# "never seen". Handed to asyncssh as `known_hosts=<these bytes>` and never
|
||||
# as None, which turns host key checking off altogether.
|
||||
host_key: Mapped[str] = mapped_column(Text, default="")
|
||||
# The SHA256 fingerprint of the above, so the profile page can show what was
|
||||
# accepted without parsing the line again on every render.
|
||||
host_fingerprint: Mapped[str] = mapped_column(String(120), default="")
|
||||
|
||||
# Where a chat starts by default. A chat records its own, chosen when it is
|
||||
# created and fixed thereafter; this is only the suggestion in the picker.
|
||||
default_dir: Mapped[str] = mapped_column(String(500), default="")
|
||||
|
||||
connect_timeout: Mapped[int] = mapped_column(Integer, default=15, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# What the last connection attempt found, for the list. `server_banner` is
|
||||
# whatever the host said about itself -- useful for telling two containers
|
||||
# apart.
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||
server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
owner: Mapped[User] = relationship()
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.name or f"{self.username}@{self.host}"
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
return f"{self.username}@{self.host}" + (f":{self.port}" if self.port != 22 else "")
|
||||
|
||||
@property
|
||||
def verified(self) -> bool:
|
||||
"""Whether this host's key has been seen and pinned."""
|
||||
return bool(self.host_key)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SshProfile {self.name} {self.address}>"
|
||||
|
||||
|
||||
__all__ = ["AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", "SshProfile"]
|
||||
@@ -22,6 +22,17 @@ ROLE_USER = "user"
|
||||
ROLE_ASSISTANT = "assistant"
|
||||
ROLE_TOOL = "tool"
|
||||
|
||||
# What a conversation is allowed to be. A plain chat can never act; an agent
|
||||
# chat is pointed at a machine before it starts and stays pointed there.
|
||||
KIND_CHAT = "chat"
|
||||
KIND_AGENT = "agent"
|
||||
KINDS = (KIND_CHAT, KIND_AGENT)
|
||||
|
||||
# Duplicated from services/agent/policy.py rather than imported: a model module
|
||||
# importing a service would invert the dependency, and this is only the column
|
||||
# default. policy.MODES is the vocabulary; this is what a row starts as.
|
||||
MODE_MANUAL = "manual"
|
||||
|
||||
|
||||
class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""A user-owned, arbitrarily nested container for chats."""
|
||||
@@ -109,6 +120,31 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
|
||||
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# --- Agent chats ---------------------------------------------------------
|
||||
# Whether this conversation may act, and where. Chosen on the new-chat
|
||||
# screen and fixed once there is a message: the harness, the tools offered
|
||||
# and the approval loop all differ, so a chat that changed kind halfway
|
||||
# would have a transcript whose earlier turns were produced under other
|
||||
# rules. The connection is locked with it -- a shell history and a project
|
||||
# directory do not transplant to another machine.
|
||||
kind: Mapped[str] = mapped_column(String(16), default=KIND_CHAT, nullable=False)
|
||||
# A plain id rather than a ForeignKey, for the reason `compacted_through_id`
|
||||
# below gives: migrations.py compiles only the column type, so a REFERENCES
|
||||
# clause would exist on a fresh database and not on an upgraded one.
|
||||
# Validated on read instead.
|
||||
ssh_profile_id: Mapped[str | None] = mapped_column(String(32))
|
||||
# Where commands start on the far side, and what file paths resolve against.
|
||||
project_dir: Mapped[str] = mapped_column(String(500), default="")
|
||||
# Which of the four permission modes is in force. The one agent field that
|
||||
# IS switchable mid-chat: it decides what gets asked about, not what the
|
||||
# conversation is.
|
||||
agent_mode: Mapped[str] = mapped_column(String(16), default=MODE_MANUAL, nullable=False)
|
||||
# Set when a turn was edited or regenerated in an agent chat. The project
|
||||
# directory is deliberately NOT rewound with the transcript -- it is
|
||||
# somebody's real working tree and deleting their work would be far worse
|
||||
# than an inconsistency -- so the harness says so instead.
|
||||
rewound_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# --- Compaction ----------------------------------------------------------
|
||||
# A summary of the turns up to `compacted_through_id`, sent in their place.
|
||||
# The messages themselves are kept and still shown; they simply stop being
|
||||
@@ -174,6 +210,11 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
|
||||
tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
|
||||
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# A plan produced in Plan mode: {"title": str, "steps": [str, ...]}. Marked
|
||||
# on the row rather than parsed back out of the prose, so the Execute button
|
||||
# sends exactly what was proposed and not an approximation of it.
|
||||
plan_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# Non-empty when generation failed. Rendered as a styled error in the
|
||||
# thread so a failed turn is never an unexplained blank bubble.
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
@@ -14,6 +14,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from lembas import __version__
|
||||
from lembas.api import (
|
||||
admin,
|
||||
admin_agents,
|
||||
admin_audio,
|
||||
admin_models,
|
||||
admin_prompts,
|
||||
@@ -21,6 +22,7 @@ from lembas.api import (
|
||||
admin_suggestions,
|
||||
admin_tools,
|
||||
admin_users,
|
||||
agents,
|
||||
audio,
|
||||
auth,
|
||||
chats,
|
||||
@@ -114,6 +116,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(files.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_models.router)
|
||||
@@ -122,6 +125,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(admin_prompts.router)
|
||||
app.include_router(admin_suggestions.router)
|
||||
app.include_router(admin_tools.router)
|
||||
app.include_router(admin_agents.router)
|
||||
|
||||
register_error_handlers(app)
|
||||
return app
|
||||
|
||||
@@ -102,6 +102,23 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"agent.ssh",
|
||||
"Save SSH connections",
|
||||
"Keep connection profiles for machines of their own. The credential is "
|
||||
"encrypted here, and whoever saves it decides which host it opens.",
|
||||
False,
|
||||
"Agent",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.agent",
|
||||
"Run commands",
|
||||
"Let a model read files, write files and run commands on one of their "
|
||||
"SSH connections. What it may do without asking depends on the chat's "
|
||||
"mode. Nothing runs on this server.",
|
||||
False,
|
||||
"Agent",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.ask",
|
||||
"Be asked questions",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""What an agent chat needs from the machine it acts on.
|
||||
|
||||
One interface, currently one implementation. It exists as an interface anyway
|
||||
because the *snapshot* is the load-bearing part: a generation outlives the
|
||||
request that started it, so everything a runner needs -- the host, the decrypted
|
||||
credential, the mode, the project directory -- has to be read while the session
|
||||
is open and carried, not looked up later. That is the same reason `Endpoint` is
|
||||
a frozen copy of a `Connection` and `ToolContext` holds an owner id rather than
|
||||
a `User`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
# What a command may weigh before it is cut off. Per call; the reply also has a
|
||||
# total, in policy.Limits.
|
||||
DEFAULT_MAX_BYTES = 64 * 1024
|
||||
DEFAULT_TIMEOUT = 60.0
|
||||
|
||||
# Terminal escape sequences, stripped from anything a command produced. They are
|
||||
# inert in escaped HTML, but this text also re-enters the model's context, where
|
||||
# they are a known way of hiding instructions, and it may end up in a log a
|
||||
# person later cats, where they hijack the terminal.
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecRequest:
|
||||
"""One command to run."""
|
||||
|
||||
command: str
|
||||
cwd: str = ""
|
||||
timeout: float = DEFAULT_TIMEOUT
|
||||
max_bytes: int = DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecResult:
|
||||
"""What running it produced.
|
||||
|
||||
`output` is stdout and stderr interleaved, because a shell transcript is
|
||||
what the model needs to read and separating them loses the ordering that
|
||||
makes an error make sense.
|
||||
"""
|
||||
|
||||
exit_status: int
|
||||
output: str
|
||||
truncated: bool = False
|
||||
timed_out: bool = False
|
||||
duration_ms: int = 0
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.exit_status == 0 and not self.timed_out
|
||||
|
||||
|
||||
class ExecError(Exception):
|
||||
"""Nothing could be run at all: the host refused, or the credential did.
|
||||
|
||||
Distinct from a command that ran and failed -- that is an `ExecResult` with
|
||||
a non-zero status, which the model should read and react to. This is the
|
||||
reply not being able to act, which is a message for a person.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
"""A machine an agent chat acts on, read while the session was open.
|
||||
|
||||
Holds the decrypted credential and nothing else does. `generation` clears it
|
||||
when the reply ends, because a finished `Generation` lingers for five
|
||||
minutes so late followers get the final frames, and a private key should not
|
||||
linger with it.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
label: str
|
||||
project_dir: str = ""
|
||||
spec: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Executor(Protocol):
|
||||
"""How a target is acted on. See `ssh.py`; there is no local variant."""
|
||||
|
||||
async def run(self, request: ExecRequest) -> ExecResult: ...
|
||||
|
||||
async def read_file(self, path: str, *, max_bytes: int) -> str: ...
|
||||
|
||||
async def write_file(self, path: str, text: str) -> int: ...
|
||||
|
||||
async def list_dir(self, path: str) -> list[str]: ...
|
||||
|
||||
|
||||
def clean_output(data: bytes | str, *, limit: int) -> tuple[str, bool]:
|
||||
"""Decode, strip escape sequences, and cap. Returns (text, truncated)."""
|
||||
text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data
|
||||
text = _ANSI.sub("", text)
|
||||
if len(text) <= limit:
|
||||
return text, False
|
||||
return text[:limit].rstrip() + "\n… (truncated)", True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_BYTES",
|
||||
"DEFAULT_TIMEOUT",
|
||||
"ExecError",
|
||||
"ExecRequest",
|
||||
"ExecResult",
|
||||
"Executor",
|
||||
"Target",
|
||||
"clean_output",
|
||||
]
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Acting on a machine over SSH.
|
||||
|
||||
Connections are made per call, for the reason MCP sessions are, plus one more: a
|
||||
live `SSHClientConnection` is exactly the kind of state `ToolContext` exists so
|
||||
that nothing holds. A command is already a network round trip inside a reply
|
||||
that takes seconds, so a second one to open the channel is not the cost worth
|
||||
optimising.
|
||||
|
||||
**Four asyncssh defaults are actively wrong here, and all four are passed
|
||||
explicitly on every connection.** Every LLeMbas user shares one unix account, so
|
||||
"whatever the account has lying around" is never the right answer:
|
||||
|
||||
* `known_hosts` unset reads that shared `~/.ssh/known_hosts` -- one trust store
|
||||
for everybody. Set to `None` it disables host key checking altogether, which
|
||||
is never correct and is the single easiest way to make this insecure.
|
||||
* `client_keys` unset loads `~/.ssh/id_*`, so one person's chat could
|
||||
authenticate with a key another person left there, or with the server's own.
|
||||
* `config` unset reads `~/.ssh/config`, where a `Hostname` or `ProxyCommand`
|
||||
can send the connection somewhere else entirely.
|
||||
* `agent_path` unset silently uses `$SSH_AUTH_SOCK`.
|
||||
|
||||
`asyncssh` is an optional dependency, imported inside the functions that need it
|
||||
so an instance with agents switched off never pays for it and an instance that
|
||||
forgot to install it gets a sentence rather than an ImportError at startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from lembas.db.models import AUTH_PASSWORD, SshProfile
|
||||
from lembas.services.agent.base import (
|
||||
ExecError,
|
||||
ExecRequest,
|
||||
ExecResult,
|
||||
clean_output,
|
||||
)
|
||||
from lembas.services.crypto import decrypt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A file read into a model's context, and one written out of it. Both bounded:
|
||||
# the first because a 40 MB log would fill the window, the second because
|
||||
# nothing a model writes in one call should be larger than this.
|
||||
MAX_READ_BYTES = 256 * 1024
|
||||
MAX_WRITE_BYTES = 1024 * 1024
|
||||
|
||||
# How many entries a directory listing returns before it is cut short.
|
||||
MAX_ENTRIES = 500
|
||||
|
||||
INSTALL_HINT = (
|
||||
"SSH support is not installed. Run `pip install -e \".[ssh]\"` in the "
|
||||
"LLeMbas virtual environment and restart."
|
||||
)
|
||||
|
||||
|
||||
def available() -> str:
|
||||
"""Empty when SSH can be used, else why it cannot.
|
||||
|
||||
Shaped like `search.availability`, and used the same way: the feature stays
|
||||
visible in the UI with an install hint rather than silently missing.
|
||||
"""
|
||||
try:
|
||||
import asyncssh # noqa: F401
|
||||
except ImportError:
|
||||
return INSTALL_HINT
|
||||
return ""
|
||||
|
||||
|
||||
def spec_from(profile: SshProfile) -> dict[str, Any]:
|
||||
"""A session-free snapshot of one profile, credential decrypted.
|
||||
|
||||
Called while the session is open. The plaintext lives in the returned dict
|
||||
and nowhere else; `generation` drops it when the reply ends.
|
||||
"""
|
||||
return {
|
||||
"id": profile.id,
|
||||
"label": profile.label,
|
||||
"host": profile.host,
|
||||
"port": int(profile.port or 22),
|
||||
"username": profile.username,
|
||||
"auth": profile.auth,
|
||||
"password": decrypt(profile.password_encrypted),
|
||||
"private_key": decrypt(profile.private_key_encrypted),
|
||||
"key_passphrase": decrypt(profile.key_passphrase_encrypted),
|
||||
"host_key": profile.host_key,
|
||||
"connect_timeout": int(profile.connect_timeout or 15),
|
||||
}
|
||||
|
||||
|
||||
def _connect_kwargs(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Everything asyncssh must be told rather than left to discover.
|
||||
|
||||
See the module docstring: every one of these has a default that is wrong
|
||||
when one unix account is shared by every user of the instance.
|
||||
"""
|
||||
if not spec.get("host_key"):
|
||||
raise ExecError(
|
||||
"This connection's host key has not been confirmed yet. Open it "
|
||||
"under Agents and press Check, then accept the fingerprint."
|
||||
)
|
||||
|
||||
keys: list = []
|
||||
if spec.get("auth") != AUTH_PASSWORD and spec.get("private_key"):
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
keys = [
|
||||
asyncssh.import_private_key(
|
||||
spec["private_key"], passphrase=spec.get("key_passphrase") or None
|
||||
)
|
||||
]
|
||||
except Exception as exc: # noqa: BLE001 - any failure here is one message
|
||||
raise ExecError(f"That private key could not be read: {exc}") from exc
|
||||
|
||||
timeout = int(spec.get("connect_timeout") or 15)
|
||||
return {
|
||||
"username": spec["username"],
|
||||
"port": int(spec.get("port") or 22),
|
||||
# Bytes, never None. None turns host key checking off entirely.
|
||||
"known_hosts": spec["host_key"].encode(),
|
||||
"client_keys": keys,
|
||||
"password": (spec.get("password") or None) if spec.get("auth") == AUTH_PASSWORD else None,
|
||||
"config": None,
|
||||
"agent_path": None,
|
||||
"connect_timeout": timeout,
|
||||
"login_timeout": timeout,
|
||||
}
|
||||
|
||||
|
||||
async def capture_host_key(host: str, port: int, *, timeout: int = 15) -> tuple[str, str]:
|
||||
"""The host's key as a known_hosts line, and its SHA256 fingerprint.
|
||||
|
||||
`get_server_host_key` completes the key exchange and stops, so nothing is
|
||||
offered to a host that has not been accepted yet -- no username, no
|
||||
password, no key. That is what makes trust-on-first-use safe to do from a
|
||||
button rather than only from a terminal.
|
||||
"""
|
||||
if problem := available():
|
||||
raise ExecError(problem)
|
||||
import asyncio
|
||||
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
key = await asyncio.wait_for(
|
||||
asyncssh.get_server_host_key(host, port=port), timeout=timeout
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
raise ExecError(f"{host} did not answer within {timeout}s.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise ExecError(f"Could not reach {host}: {exc}") from exc
|
||||
|
||||
if key is None:
|
||||
raise ExecError(f"{host} offered no host key.")
|
||||
|
||||
algorithm = key.get_algorithm()
|
||||
encoded = key.export_public_key("openssh").decode().split()[1]
|
||||
where = f"[{host}]:{port}" if port != 22 else host
|
||||
return f"{where} {algorithm} {encoded}\n", key.get_fingerprint("sha256")
|
||||
|
||||
|
||||
class SshExecutor:
|
||||
"""One target, reached over SSH. A connection per call."""
|
||||
|
||||
def __init__(self, spec: dict[str, Any], project_dir: str = "") -> None:
|
||||
self.spec = spec
|
||||
self.project_dir = project_dir or ""
|
||||
self.label = str(spec.get("label") or spec.get("host") or "the remote host")
|
||||
|
||||
def _connect(self):
|
||||
if problem := available():
|
||||
raise ExecError(problem)
|
||||
import asyncssh
|
||||
|
||||
return asyncssh.connect(self.spec["host"], **_connect_kwargs(self.spec))
|
||||
|
||||
def _wrap(self, exc: Exception) -> ExecError:
|
||||
import asyncssh
|
||||
|
||||
if isinstance(exc, asyncssh.HostKeyNotVerifiable):
|
||||
return ExecError(
|
||||
f"{self.label} presented a different host key than the one that "
|
||||
"was confirmed. Nothing was sent. If the host was rebuilt, open "
|
||||
"it under Agents and confirm the new fingerprint."
|
||||
)
|
||||
if isinstance(exc, asyncssh.PermissionDenied):
|
||||
return ExecError(f"{self.label} refused the credential.")
|
||||
return ExecError(f"Could not reach {self.label}: {exc}")
|
||||
|
||||
async def run(self, request: ExecRequest) -> ExecResult:
|
||||
"""Run one command and read back what it said.
|
||||
|
||||
Every command is a fresh shell, so `cd` does not carry between calls --
|
||||
the working directory is set here, from `cwd` or the chat's project
|
||||
directory, and never spliced into the command string.
|
||||
"""
|
||||
import asyncssh
|
||||
|
||||
started = time.monotonic()
|
||||
directory = request.cwd or self.project_dir
|
||||
# A single-quoted path, with any embedded quote escaped. `cd` needs a
|
||||
# shell, so this is the one place a path meets one -- and it is a path
|
||||
# from the chat's own configuration, not from the model, except when the
|
||||
# model passed `cwd`, which is why it is quoted rather than trusted.
|
||||
command = request.command
|
||||
if directory:
|
||||
command = f"cd {_quote(directory)} && {command}"
|
||||
|
||||
try:
|
||||
async with self._connect() as conn:
|
||||
result = await conn.run(
|
||||
command,
|
||||
check=False,
|
||||
timeout=request.timeout,
|
||||
# Interleaved, because a shell transcript is what the model
|
||||
# has to read and separating them loses the ordering.
|
||||
stderr=asyncssh.STDOUT,
|
||||
# A command that waits for input fails at once instead of
|
||||
# sitting out its whole timeout in silence.
|
||||
stdin=asyncssh.DEVNULL,
|
||||
)
|
||||
except TimeoutError:
|
||||
elapsed = int((time.monotonic() - started) * 1000)
|
||||
return ExecResult(
|
||||
exit_status=-1,
|
||||
output=f"The command was still running after {request.timeout:g}s and was stopped.",
|
||||
timed_out=True,
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
output, truncated = clean_output(result.stdout or "", limit=request.max_bytes)
|
||||
return ExecResult(
|
||||
exit_status=result.exit_status if result.exit_status is not None else -1,
|
||||
output=output,
|
||||
truncated=truncated,
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
|
||||
# --- Files go over SFTP, never through a shell ---------------------------
|
||||
# The SSH exec protocol carries one command *string* that the far side's
|
||||
# shell parses; there is no argv form. So a path in a command line is
|
||||
# unavoidably a quoting problem, and a model-supplied path is exactly the
|
||||
# input that must not become one. Over SFTP a path is a path.
|
||||
async def read_file(self, path: str, *, max_bytes: int = MAX_READ_BYTES) -> str:
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
async with (
|
||||
self._connect() as conn,
|
||||
conn.start_sftp_client() as sftp,
|
||||
sftp.open(self._resolve(path), "rb") as handle,
|
||||
):
|
||||
data = await handle.read(max_bytes + 1)
|
||||
except asyncssh.SFTPNoSuchFile as exc:
|
||||
raise ExecError(f"There is no file at {path}.") from exc
|
||||
except asyncssh.SFTPPermissionDenied as exc:
|
||||
raise ExecError(f"Not allowed to read {path}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
text, _truncated = clean_output(data[:max_bytes], limit=max_bytes)
|
||||
return text
|
||||
|
||||
async def write_file(self, path: str, text: str) -> int:
|
||||
import asyncssh
|
||||
|
||||
payload = text.encode("utf-8")[:MAX_WRITE_BYTES]
|
||||
try:
|
||||
async with (
|
||||
self._connect() as conn,
|
||||
conn.start_sftp_client() as sftp,
|
||||
sftp.open(self._resolve(path), "wb") as handle,
|
||||
):
|
||||
await handle.write(payload)
|
||||
except asyncssh.SFTPPermissionDenied as exc:
|
||||
raise ExecError(f"Not allowed to write {path}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
return len(payload)
|
||||
|
||||
async def list_dir(self, path: str = "") -> list[str]:
|
||||
import asyncssh
|
||||
|
||||
try:
|
||||
async with self._connect() as conn, conn.start_sftp_client() as sftp:
|
||||
target = self._resolve(path) if path else (self.project_dir or ".")
|
||||
names = await sftp.listdir(target)
|
||||
except asyncssh.SFTPNoSuchFile as exc:
|
||||
raise ExecError(f"There is no directory at {path or self.project_dir}.") from exc
|
||||
except (OSError, asyncssh.Error) as exc:
|
||||
raise self._wrap(exc) from exc
|
||||
|
||||
visible = sorted(n for n in names if n not in (".", ".."))
|
||||
return visible[:MAX_ENTRIES]
|
||||
|
||||
def _resolve(self, path: str) -> str:
|
||||
"""A path relative to the project directory, unless it is absolute.
|
||||
|
||||
Deliberately *not* a containment check. The account on the far side is
|
||||
the boundary -- a profile whose user can only see /srv/project can only
|
||||
reach things under it -- and pretending otherwise here would be a
|
||||
comfort rather than a control, since `shell_run` could walk out of it in
|
||||
one line anyway.
|
||||
"""
|
||||
if not path:
|
||||
return self.project_dir or "."
|
||||
if path.startswith("/") or not self.project_dir:
|
||||
return path
|
||||
return f"{self.project_dir.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
async def check(spec: dict[str, Any], project_dir: str = "") -> dict[str, Any]:
|
||||
"""Connect, confirm the project directory, and report what was found.
|
||||
|
||||
Used by the Check button on a profile. Runs one harmless command rather than
|
||||
only opening a connection, because "the credential works" and "the directory
|
||||
is there" are the two things somebody is actually asking about.
|
||||
"""
|
||||
executor = SshExecutor(spec, project_dir)
|
||||
result = await executor.run(
|
||||
ExecRequest(command="uname -sr 2>/dev/null; pwd", timeout=15, max_bytes=4096)
|
||||
)
|
||||
lines = [line for line in result.output.splitlines() if line.strip()]
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"system": lines[0] if lines else "",
|
||||
"cwd": lines[-1] if len(lines) > 1 else "",
|
||||
"output": result.output,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INSTALL_HINT",
|
||||
"MAX_READ_BYTES",
|
||||
"SshExecutor",
|
||||
"available",
|
||||
"capture_host_key",
|
||||
"check",
|
||||
"spec_from",
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""SSH connections, and who may do what with them.
|
||||
|
||||
The pages are user-facing rather than admin, because these are somebody's own
|
||||
machines and somebody's own keys. Most of what is worth pinning here is about
|
||||
that ownership, and about the host key -- the one thing standing between "this
|
||||
is my container" and "this is something answering on its address".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import SshProfile, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt
|
||||
|
||||
asyncssh = pytest.importorskip("asyncssh")
|
||||
|
||||
|
||||
def _form(**overrides) -> dict:
|
||||
base = {
|
||||
"name": "Project box",
|
||||
"host": "127.0.0.1",
|
||||
"port": "2222",
|
||||
"username": "root",
|
||||
"default_dir": "/project",
|
||||
"connect_timeout": "15",
|
||||
"auth": "key",
|
||||
"enabled": "true",
|
||||
}
|
||||
base.update(overrides)
|
||||
return {k: v for k, v in base.items() if v is not None}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ssh_host():
|
||||
"""A real SSH server, on a thread and an event loop of its own.
|
||||
|
||||
Its own loop matters: these tests drive the app through the synchronous
|
||||
TestClient, so a server sharing the test's loop could not accept a
|
||||
connection while a `client.post(...)` was blocking it, and the check would
|
||||
time out rather than succeed. Yields the port it is listening on.
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
async def start():
|
||||
server = await asyncssh.create_server(
|
||||
asyncssh.SSHServer,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
|
||||
)
|
||||
return server, next(iter(server.sockets)).getsockname()[1]
|
||||
|
||||
server, port = asyncio.run_coroutine_threadsafe(start(), loop).result(10)
|
||||
try:
|
||||
yield port
|
||||
finally:
|
||||
|
||||
async def stop():
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
asyncio.run_coroutine_threadsafe(stop(), loop).result(10)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def _grant(db, user_id: str) -> None:
|
||||
"""Give a plain account the two agent permissions."""
|
||||
settings_store.update(
|
||||
db, {"default_permissions": {"agent.ssh": True, "tools.agent": True}}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def second_user(client: TestClient, db, registered):
|
||||
"""Another signed-in account, so ownership can be tested."""
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Sam", "email": "sam@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
|
||||
user.role = "user"
|
||||
user.active = True
|
||||
db.commit()
|
||||
_grant(db, user.id)
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "sam@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
# --- Guards --------------------------------------------------------------------
|
||||
def test_the_pages_need_the_permission(client: TestClient, db, registered):
|
||||
"""Administrators pass everything, so this needs a plain account."""
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Merry", "email": "m@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == "m@shire.test"))
|
||||
user.role = "user"
|
||||
user.active = True
|
||||
db.commit()
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "m@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert client.get("/agents").status_code == 403
|
||||
assert client.post("/api/agents", data=_form()).status_code == 403
|
||||
|
||||
|
||||
def test_new_is_not_parsed_as_a_profile_id(client: TestClient, registered):
|
||||
response = client.get("/agents/new")
|
||||
assert response.status_code == 200
|
||||
assert "New connection" in response.text
|
||||
|
||||
|
||||
# --- Creating and editing ------------------------------------------------------
|
||||
def test_creating_a_connection(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
|
||||
|
||||
profile = db.scalar(select(SshProfile))
|
||||
assert profile.name == "Project box"
|
||||
assert profile.port == 2222
|
||||
assert profile.default_dir == "/project"
|
||||
assert decrypt(profile.private_key_encrypted) == "KEY MATERIAL"
|
||||
assert profile.verified is False, "nothing is trusted until a key is accepted"
|
||||
|
||||
|
||||
def test_a_duplicate_name_is_refused_per_person(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
response = client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
|
||||
assert "already have a connection" in response.text
|
||||
assert len(list(db.scalars(select(SshProfile)))) == 1
|
||||
|
||||
|
||||
def test_two_people_may_use_the_same_name(client: TestClient, db, registered, second_user):
|
||||
"""The uniqueness is per owner. Two people each calling theirs "box" is not
|
||||
a conflict, and treating it as one would be a surprise."""
|
||||
client.post("/api/agents", data=_form(name="box"), follow_redirects=False)
|
||||
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "frodo@shire.test", "password": "speak-friend-and-enter"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
client.post("/api/agents", data=_form(name="box"), follow_redirects=False)
|
||||
|
||||
assert len(list(db.scalars(select(SshProfile)))) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "message"),
|
||||
[("name", "needs a name"), ("host", "needs a host"), ("username", "username")],
|
||||
)
|
||||
def test_the_essentials_are_required(client: TestClient, db, registered, field, message):
|
||||
response = client.post("/api/agents", data=_form(**{field: ""}), follow_redirects=False)
|
||||
assert message in response.text
|
||||
assert db.scalar(select(SshProfile)) is None
|
||||
|
||||
|
||||
def test_a_secret_is_never_rendered_in_full(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(private_key="SUPER SECRET KEY"), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
page = client.get(f"/agents/{profile.id}").text
|
||||
assert "SUPER SECRET KEY" not in page
|
||||
assert UNCHANGED_SENTINEL in page
|
||||
|
||||
|
||||
def test_leaving_the_dots_alone_keeps_the_key(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
client.post(
|
||||
f"/api/agents/{profile.id}",
|
||||
data=_form(private_key=UNCHANGED_SENTINEL),
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(profile)
|
||||
assert decrypt(profile.private_key_encrypted) == "KEY MATERIAL"
|
||||
|
||||
|
||||
def test_switching_to_a_password_drops_the_key(client: TestClient, db, registered):
|
||||
"""Keeping a key that is no longer used would leave a credential lying in
|
||||
the database with nothing pointing at it."""
|
||||
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
client.post(
|
||||
f"/api/agents/{profile.id}",
|
||||
data=_form(auth="password", password="hunter2", private_key=UNCHANGED_SENTINEL),
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(profile)
|
||||
assert profile.private_key_encrypted == ""
|
||||
assert decrypt(profile.password_encrypted) == "hunter2"
|
||||
|
||||
|
||||
# --- Ownership ------------------------------------------------------------------
|
||||
def test_another_account_cannot_see_or_touch_your_connection(
|
||||
client: TestClient, db, registered, second_user
|
||||
):
|
||||
"""`sharing.py` is deliberately not involved: it grants reading, and a host
|
||||
somebody else can read is a host they can log in to."""
|
||||
mine = SshProfile(
|
||||
owner_id=db.scalar(select(User).where(User.email == "frodo@shire.test")).id,
|
||||
name="Not yours",
|
||||
host="10.0.0.5",
|
||||
username="root",
|
||||
)
|
||||
db.add(mine)
|
||||
db.commit()
|
||||
|
||||
# `second_user` is the one signed in.
|
||||
assert client.get(f"/agents/{mine.id}").status_code == 404
|
||||
assert client.post(f"/api/agents/{mine.id}", data=_form()).status_code == 404
|
||||
assert client.post(f"/api/agents/{mine.id}/delete").status_code == 404
|
||||
assert client.post(f"/api/agents/{mine.id}/check").status_code == 404
|
||||
assert client.get("/agents").text.count("Not yours") == 0
|
||||
|
||||
|
||||
def test_deleting_a_connection(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
client.post(f"/api/agents/{profile.id}/delete", follow_redirects=False)
|
||||
assert db.scalar(select(SshProfile)) is None
|
||||
|
||||
|
||||
# --- The host key ----------------------------------------------------------------
|
||||
def test_checking_an_unseen_host_offers_a_fingerprint_and_pins_nothing(
|
||||
client: TestClient, db, registered, ssh_host
|
||||
):
|
||||
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
response = client.post(f"/api/agents/{profile.id}/check")
|
||||
assert "SHA256:" in response.text
|
||||
assert "Accept and pin" in response.text
|
||||
|
||||
db.refresh(profile)
|
||||
assert profile.host_key == "", "looking is not accepting"
|
||||
|
||||
|
||||
def test_accepting_pins_the_key_and_the_fingerprint(
|
||||
client: TestClient, db, registered, ssh_host
|
||||
):
|
||||
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
response = client.post(f"/api/agents/{profile.id}/accept")
|
||||
assert "Pinned" in response.text
|
||||
|
||||
db.refresh(profile)
|
||||
assert profile.verified is True
|
||||
assert profile.host_fingerprint.startswith("SHA256:")
|
||||
assert "ssh-ed25519" in profile.host_key
|
||||
|
||||
|
||||
def test_a_host_whose_key_changed_is_reported_and_not_silently_accepted(
|
||||
client: TestClient, db, registered, ssh_host
|
||||
):
|
||||
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
# Pin something else entirely.
|
||||
profile.host_key = f"[127.0.0.1]:{ssh_host} ssh-ed25519 {'A' * 68}\n"
|
||||
profile.host_fingerprint = "SHA256:old"
|
||||
db.commit()
|
||||
|
||||
response = client.post(f"/api/agents/{profile.id}/check")
|
||||
assert "different key" in response.text
|
||||
assert "Nothing was sent" in response.text
|
||||
|
||||
db.refresh(profile)
|
||||
assert profile.host_fingerprint == "SHA256:old", "the old pin is left alone"
|
||||
|
||||
|
||||
def test_moving_a_connection_to_another_host_forgets_its_key(client: TestClient, db, registered):
|
||||
"""A pinned key belongs to a host and a port. Keeping it across a move is the
|
||||
one mistake the whole mechanism exists to prevent."""
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
|
||||
profile.host_fingerprint = "SHA256:whatever"
|
||||
db.commit()
|
||||
|
||||
client.post(f"/api/agents/{profile.id}", data=_form(host="10.0.0.9"), follow_redirects=False)
|
||||
db.refresh(profile)
|
||||
|
||||
assert profile.host_key == ""
|
||||
assert profile.host_fingerprint == ""
|
||||
|
||||
|
||||
def test_editing_something_harmless_keeps_the_key(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
|
||||
db.commit()
|
||||
|
||||
client.post(
|
||||
f"/api/agents/{profile.id}", data=_form(default_dir="/elsewhere"), follow_redirects=False
|
||||
)
|
||||
db.refresh(profile)
|
||||
|
||||
assert profile.host_key == "127.0.0.1 ssh-ed25519 AAAA\n"
|
||||
assert profile.default_dir == "/elsewhere"
|
||||
|
||||
|
||||
def test_forgetting_a_key_clears_it(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
|
||||
db.commit()
|
||||
|
||||
client.post(f"/api/agents/{profile.id}/forget")
|
||||
db.refresh(profile)
|
||||
assert profile.host_key == ""
|
||||
|
||||
|
||||
# --- The admin half --------------------------------------------------------------
|
||||
def test_the_admin_page_is_refused_to_a_plain_user(client: TestClient, db, registered):
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Pip", "email": "p@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == "p@shire.test"))
|
||||
user.role = "user"
|
||||
user.active = True
|
||||
db.commit()
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "p@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert client.get("/admin/agents").status_code == 403
|
||||
|
||||
|
||||
def test_agents_are_off_until_an_administrator_says_otherwise(client: TestClient, db, registered):
|
||||
assert settings_store.agents(db)["enabled"] is False
|
||||
|
||||
client.post(
|
||||
"/admin/agents",
|
||||
data={
|
||||
"enabled": "true",
|
||||
"default_timeout": "30",
|
||||
"max_timeout": "600",
|
||||
"max_output_bytes": "65536",
|
||||
"max_steps": "40",
|
||||
"max_wall_seconds": "900",
|
||||
"max_total_output_bytes": "1048576",
|
||||
"approval_timeout": "900",
|
||||
"allow_default": "file_read\ngit *\n\n",
|
||||
"deny_default": "shutdown *",
|
||||
"ask_free_text": "true",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
values = settings_store.agents(db)
|
||||
assert values["enabled"] is True
|
||||
assert values["default_timeout"] == 30
|
||||
assert values["allow_default"] == ["file_read", "git *"], "blank lines dropped"
|
||||
assert values["deny_default"] == ["shutdown *"]
|
||||
|
||||
|
||||
def test_the_numbers_are_clamped(client: TestClient, db, registered):
|
||||
client.post(
|
||||
"/admin/agents",
|
||||
data={
|
||||
"enabled": "true",
|
||||
"default_timeout": "0",
|
||||
"max_timeout": "99999",
|
||||
"max_output_bytes": "1",
|
||||
"max_steps": "9999",
|
||||
"max_wall_seconds": "1",
|
||||
"max_total_output_bytes": "1",
|
||||
"approval_timeout": "0",
|
||||
"allow_default": "",
|
||||
"deny_default": "",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
values = settings_store.agents(db)
|
||||
assert values["default_timeout"] == 1
|
||||
assert values["max_timeout"] == 3600
|
||||
assert values["max_steps"] == 200
|
||||
assert values["approval_timeout"] == 60, "a zero would park a task forever"
|
||||
|
||||
|
||||
def test_an_unticked_checkbox_turns_it_off(client: TestClient, db, registered):
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
|
||||
client.post(
|
||||
"/admin/agents",
|
||||
data={
|
||||
"default_timeout": "60",
|
||||
"max_timeout": "600",
|
||||
"max_output_bytes": "65536",
|
||||
"max_steps": "40",
|
||||
"max_wall_seconds": "900",
|
||||
"max_total_output_bytes": "1048576",
|
||||
"approval_timeout": "900",
|
||||
"allow_default": "",
|
||||
"deny_default": "",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert settings_store.agents(db)["enabled"] is False
|
||||
|
||||
|
||||
def test_encrypted_credentials_never_appear_in_the_database_in_the_clear(db, user_id):
|
||||
profile = SshProfile(
|
||||
owner_id=user_id,
|
||||
name="box",
|
||||
host="h",
|
||||
username="u",
|
||||
password_encrypted=encrypt("hunter2"),
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
|
||||
raw = db.execute(
|
||||
select(SshProfile.password_encrypted).where(SshProfile.id == profile.id)
|
||||
).scalar_one()
|
||||
assert "hunter2" not in raw
|
||||
assert decrypt(raw) == "hunter2"
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Acting on a machine over SSH.
|
||||
|
||||
Driven against a real asyncssh server on 127.0.0.1 with a generated host key, so
|
||||
nothing here touches an outside network and the host-key path is exercised for
|
||||
real rather than mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from lembas.services.agent import ssh
|
||||
from lembas.services.agent.base import ExecError, ExecRequest
|
||||
|
||||
asyncssh = pytest.importorskip("asyncssh")
|
||||
|
||||
|
||||
# --- A server to talk to -------------------------------------------------------
|
||||
def _generate_key():
|
||||
return asyncssh.generate_private_key("ssh-ed25519")
|
||||
|
||||
|
||||
class _Server(asyncssh.SSHServer):
|
||||
def begin_auth(self, username: str) -> bool:
|
||||
return False # no authentication wanted; every login succeeds
|
||||
|
||||
|
||||
async def _handler(process):
|
||||
"""A shell that understands just enough to be tested against."""
|
||||
command = process.command or ""
|
||||
if "sleep" in command:
|
||||
await asyncio.sleep(5)
|
||||
process.exit(0)
|
||||
return
|
||||
if "flood" in command:
|
||||
process.stdout.write("x" * 200_000)
|
||||
process.exit(0)
|
||||
return
|
||||
if "fail" in command:
|
||||
process.stderr.write("it went wrong\n")
|
||||
process.exit(3)
|
||||
return
|
||||
if "colour" in command:
|
||||
process.stdout.write("\x1b[31mred\x1b[0m\n")
|
||||
process.exit(0)
|
||||
return
|
||||
process.stdout.write(f"ran: {command}\n")
|
||||
process.exit(0)
|
||||
|
||||
|
||||
async def _start(host_key=None):
|
||||
server = await asyncssh.create_server(
|
||||
_Server,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
server_host_keys=[host_key or _generate_key()],
|
||||
process_factory=_handler,
|
||||
sftp_factory=True,
|
||||
)
|
||||
port = next(iter(server.sockets)).getsockname()[1]
|
||||
return server, port
|
||||
|
||||
|
||||
def _spec(port: int, host_key: str, **overrides) -> dict:
|
||||
base = {
|
||||
"id": "p1",
|
||||
"label": "test box",
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"username": "tester",
|
||||
"auth": "key",
|
||||
"password": "",
|
||||
"private_key": "",
|
||||
"key_passphrase": "",
|
||||
"host_key": host_key,
|
||||
"connect_timeout": 5,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def project(tmp_path):
|
||||
"""A directory of its own.
|
||||
|
||||
Not `tmp_path` itself: the autouse database fixture points the data
|
||||
directory there, so a listing would find lembas.db and the uploads folder.
|
||||
"""
|
||||
path = tmp_path / "project"
|
||||
path.mkdir()
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def box(project):
|
||||
"""A running server, its pinned host key line, and a project directory."""
|
||||
key = _generate_key()
|
||||
server, port = await _start(key)
|
||||
line, fingerprint = await ssh.capture_host_key("127.0.0.1", port)
|
||||
try:
|
||||
yield {
|
||||
"port": port,
|
||||
"host_key": line,
|
||||
"fingerprint": fingerprint,
|
||||
"dir": str(project),
|
||||
"server": server,
|
||||
}
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
# --- The four defaults that must never be left to asyncssh --------------------
|
||||
def test_the_dangerous_defaults_are_all_passed_explicitly():
|
||||
"""Every LLeMbas user shares one unix account, so "whatever the account has
|
||||
lying around" is never the right answer. This is the most important test in
|
||||
the file and it needs no server at all."""
|
||||
kwargs = ssh._connect_kwargs(_spec(22, "127.0.0.1 ssh-ed25519 AAAA\n"))
|
||||
|
||||
# Bytes, never None: None turns host key checking off entirely.
|
||||
assert isinstance(kwargs["known_hosts"], bytes)
|
||||
assert kwargs["known_hosts"] != b""
|
||||
# Not left to load ~/.ssh/id_*, which could be another person's key.
|
||||
assert kwargs["client_keys"] == []
|
||||
# Not left to read ~/.ssh/config, where ProxyCommand could redirect us.
|
||||
assert kwargs["config"] is None
|
||||
# Not left to use $SSH_AUTH_SOCK.
|
||||
assert kwargs["agent_path"] is None
|
||||
|
||||
|
||||
def test_a_profile_with_no_confirmed_host_key_refuses_to_connect():
|
||||
with pytest.raises(ExecError, match="host key has not been confirmed"):
|
||||
ssh._connect_kwargs(_spec(22, ""))
|
||||
|
||||
|
||||
def test_a_password_profile_sends_a_password_and_no_keys():
|
||||
kwargs = ssh._connect_kwargs(
|
||||
_spec(22, "h ssh-ed25519 AAAA\n", auth="password", password="hunter2")
|
||||
)
|
||||
assert kwargs["password"] == "hunter2"
|
||||
assert kwargs["client_keys"] == []
|
||||
|
||||
|
||||
def test_a_key_profile_sends_no_password():
|
||||
kwargs = ssh._connect_kwargs(_spec(22, "h ssh-ed25519 AAAA\n", password="stale"))
|
||||
assert kwargs["password"] is None
|
||||
|
||||
|
||||
# --- Trust on first use --------------------------------------------------------
|
||||
async def test_the_first_look_captures_a_key_and_a_fingerprint(box):
|
||||
assert box["host_key"].startswith(f"[127.0.0.1]:{box['port']} ssh-ed25519 ")
|
||||
assert box["fingerprint"].startswith("SHA256:")
|
||||
|
||||
|
||||
async def test_capturing_a_key_offers_no_credential():
|
||||
"""get_server_host_key completes the key exchange and stops, which is what
|
||||
makes accepting a fingerprint from a button safe: nothing is sent to a host
|
||||
that has not been accepted yet.
|
||||
|
||||
The server here records every authentication attempt, so an empty list is
|
||||
evidence rather than an absence of it.
|
||||
"""
|
||||
attempts: list[str] = []
|
||||
|
||||
class Watchful(asyncssh.SSHServer):
|
||||
def begin_auth(self, username: str) -> bool:
|
||||
attempts.append(username)
|
||||
return False
|
||||
|
||||
server = await asyncssh.create_server(
|
||||
Watchful, "127.0.0.1", 0, server_host_keys=[_generate_key()]
|
||||
)
|
||||
port = next(iter(server.sockets)).getsockname()[1]
|
||||
try:
|
||||
line, fingerprint = await ssh.capture_host_key("127.0.0.1", port)
|
||||
assert line and fingerprint.startswith("SHA256:")
|
||||
assert attempts == [], "a host not yet accepted was offered a username"
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
async def test_a_host_that_answers_with_a_different_key_is_refused(box):
|
||||
"""The pinned key is the whole of the protection. A host presenting another
|
||||
one is either rebuilt or is not the host."""
|
||||
other_line, _ = await ssh.capture_host_key("127.0.0.1", box["port"])
|
||||
wrong = other_line.rsplit(" ", 1)[0] + " " + "A" * 68 + "\n"
|
||||
|
||||
executor = ssh.SshExecutor(_spec(box["port"], wrong), box["dir"])
|
||||
with pytest.raises(ExecError):
|
||||
await executor.run(ExecRequest(command="echo hi", timeout=5))
|
||||
|
||||
|
||||
async def test_an_unreachable_host_says_so():
|
||||
with pytest.raises(ExecError, match="Could not reach"):
|
||||
await ssh.capture_host_key("127.0.0.1", 1, timeout=3)
|
||||
|
||||
|
||||
# --- Running commands ----------------------------------------------------------
|
||||
async def test_a_command_runs_and_comes_back(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
||||
|
||||
assert result.ok
|
||||
assert result.exit_status == 0
|
||||
assert "echo hi" in result.output
|
||||
|
||||
|
||||
async def test_the_working_directory_is_set_and_never_spliced_in(box):
|
||||
"""Every command is a fresh shell, so `cd` cannot carry between calls. The
|
||||
directory is quoted, because `cwd` may come from the model."""
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), "/tmp/a dir")
|
||||
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
||||
assert "cd '/tmp/a dir' && echo hi" in result.output
|
||||
|
||||
|
||||
async def test_a_directory_with_a_quote_in_it_cannot_end_the_quoting(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), "/tmp/it's; rm -rf /")
|
||||
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
||||
assert "'/tmp/it'\\''s; rm -rf /'" in result.output
|
||||
|
||||
|
||||
async def test_a_failing_command_is_a_result_not_an_error(box):
|
||||
"""A command that ran and failed is something the model should read and
|
||||
react to. Only being unable to act at all is an ExecError."""
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="fail", timeout=5))
|
||||
|
||||
assert result.exit_status == 3
|
||||
assert result.ok is False
|
||||
assert "it went wrong" in result.output, "stderr is interleaved, not dropped"
|
||||
|
||||
|
||||
async def test_a_slow_command_times_out_and_says_so(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="sleep", timeout=1))
|
||||
|
||||
assert result.timed_out is True
|
||||
assert result.ok is False
|
||||
assert "was stopped" in result.output
|
||||
|
||||
|
||||
async def test_a_flood_of_output_is_capped(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="flood", timeout=10, max_bytes=2000))
|
||||
|
||||
assert result.truncated is True
|
||||
assert len(result.output) < 2200
|
||||
assert result.output.endswith("(truncated)")
|
||||
|
||||
|
||||
async def test_terminal_escapes_are_stripped(box):
|
||||
"""Inert in escaped HTML, but this text re-enters the model's context, where
|
||||
they are a known way to hide instructions -- and a log a person later cats."""
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="colour", timeout=5))
|
||||
|
||||
assert "red" in result.output
|
||||
assert "\x1b" not in result.output
|
||||
|
||||
|
||||
# --- Files, over SFTP ----------------------------------------------------------
|
||||
async def test_a_file_is_written_and_read_back(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
|
||||
written = await executor.write_file("hello.txt", "a mallorn tree\n")
|
||||
assert written == len("a mallorn tree\n")
|
||||
assert await executor.read_file("hello.txt") == "a mallorn tree\n"
|
||||
|
||||
|
||||
async def test_a_relative_path_resolves_against_the_project_directory(box, project):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
await executor.write_file("nested.txt", "here")
|
||||
|
||||
assert (project / "nested.txt").read_text() == "here"
|
||||
|
||||
|
||||
async def test_a_missing_file_is_reported_plainly(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
with pytest.raises(ExecError, match="no file at"):
|
||||
await executor.read_file("nope.txt")
|
||||
|
||||
|
||||
async def test_a_directory_lists(box, project):
|
||||
(project / "one.txt").write_text("1")
|
||||
(project / "two.txt").write_text("2")
|
||||
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
assert await executor.list_dir() == ["one.txt", "two.txt"]
|
||||
|
||||
|
||||
async def test_reading_a_file_is_capped(box, project):
|
||||
(project / "big.txt").write_text("y" * 50_000)
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
|
||||
text = await executor.read_file("big.txt", max_bytes=1000)
|
||||
assert len(text) <= 1000
|
||||
|
||||
|
||||
# --- The check a person presses ------------------------------------------------
|
||||
async def test_check_reports_what_it_found(box):
|
||||
found = await ssh.check(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
assert found["ok"] is True
|
||||
assert "uname" in found["output"]
|
||||
|
||||
|
||||
# --- The snapshot --------------------------------------------------------------
|
||||
def test_spec_from_decrypts_the_credential_and_the_row_does_not_hold_it(db, user_id):
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
profile = SshProfile(
|
||||
owner_id=user_id,
|
||||
name="box",
|
||||
host="10.0.0.5",
|
||||
username="root",
|
||||
private_key_encrypted=encrypt("PRIVATE KEY MATERIAL"),
|
||||
host_key="h ssh-ed25519 AAAA\n",
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
|
||||
spec = ssh.spec_from(profile)
|
||||
assert spec["private_key"] == "PRIVATE KEY MATERIAL"
|
||||
assert "PRIVATE KEY MATERIAL" not in profile.private_key_encrypted
|
||||
Reference in New Issue
Block a user