"""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=` 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"" 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"" __all__ = ["AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", "Job", "SshProfile"]