"Nothing runs on the LLeMbas host" is the sentence the absent sandbox and the absent local MCP rest on, and an SSH profile aimed at 127.0.0.1 walked straight past it -- through a real login, with every gate in policy.py still applying, onto the machine holding the database and the Fernet key. From the SSH layer down it is indistinguishable from a container on the network, so nothing here could have noticed. One switch, three positions: never, one named port, anywhere. The middle one is the one with a real use -- a container that published its SSH port on the loopback interface is genuinely somewhere else -- and port 22 is refused even there, because that one is this host's own sshd. Enforced in five places, because a row can predate a setting: saving a profile, `session.resolve` (the control every agent tool, the terminal and the canvas go through), the composer's picker, browsing, and the draft the panels open against before a chat exists. Check refuses before it opens its socket rather than after. And the recognition never resolves a name on the request path. `refusal` runs several times per page render; the first version of this looked names up inline and the suite went from two minutes to not finishing. Literal forms are decided from the string, a name is settled where a network call is already expected, and the answer lives on the row. The gap that leaves is written down rather than discovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
148 lines
6.7 KiB
Python
148 lines
6.7 KiB
Python
"""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)
|
|
|
|
# Whether `host` resolved to loopback the last time anybody looked. Written
|
|
# where a network call is already happening -- saving this connection, and
|
|
# Check -- and read on every request that asks whether this connection may
|
|
# be used at all. A column rather than a lookup because that question is
|
|
# asked several times per page render, and `getaddrinfo` on the request path
|
|
# makes an agent page wait out a DNS timeout for a host nobody is talking
|
|
# to. A literal `127.0.0.1` needs none of this and is decided from the
|
|
# string. See services/agent/hosts.py.
|
|
#
|
|
# False on every row an upgrade brings in, which is correct for the literal
|
|
# case (decided from the string anyway) and optimistic for a *name* until it
|
|
# is next saved or checked.
|
|
resolves_here: Mapped[bool] = mapped_column(Boolean, default=False, 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}>"
|
|
|
|
|
|
class Job(Timestamps, Base):
|
|
"""A command left running on the far side after the reply that started it.
|
|
|
|
The durable record behind `services/agent/jobs.py`, which otherwise keeps
|
|
only an in-process registry lost on restart. A background job runs for
|
|
minutes to hours with nobody watching -- exactly the case a restart must not
|
|
forget -- so the row lets a startup hook re-poll the job's deterministic
|
|
exit-file and wake the model as if nothing had happened.
|
|
|
|
The id is `jobs`'s own short hex, not a UUIDPrimaryKey, because the same id
|
|
names the files on the machine and is quoted back by the model.
|
|
"""
|
|
|
|
__tablename__ = "agent_jobs"
|
|
|
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
chat_id: Mapped[str] = mapped_column(
|
|
String(32), ForeignKey("chats.id", ondelete="CASCADE"), index=True, nullable=False
|
|
)
|
|
command: Mapped[str] = mapped_column(Text, default="")
|
|
# running | done | killed | lost. `lost` means it stopped without an exit
|
|
# code being recorded -- killed out of band, or the host rebooted under it.
|
|
status: Mapped[str] = mapped_column(String(16), default="running", nullable=False)
|
|
exit_status: Mapped[int | None] = mapped_column(Integer)
|
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<Job {self.id} {self.status}>"
|
|
|
|
|
|
__all__ = ["AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", "Job", "SshProfile"]
|