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:
@@ -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