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:
@@ -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="")
|
||||
|
||||
Reference in New Issue
Block a user