14b1428f9f
"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"]
|