Files
LLeMbas/src/lembas/services/agent/session.py
T
Jaroslav Beneš 14b1428f9f A connection that cannot point at the machine it is running on
"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>
2026-08-06 10:07:36 +02:00

256 lines
12 KiB
Python

"""What one agent chat is pointed at, resolved while a session is open.
Everything a runner needs travels in `AgentContext`: the machine, the decrypted
credential, the mode in force, and the two lists that adjust it. Nothing is
looked up later, for the reason `Endpoint` is a frozen copy of a `Connection`
and `ToolContext` carries an owner id rather than a `User` -- a generation
outlives the request that started it, and a detached instance is a trap.
The mode is read **once, at the start of the reply**, and deliberately does not
change under a reply already in flight. Somebody switching to Auto halfway
through must not retroactively approve what is already queued.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, replace
from typing import Any
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import KIND_AGENT, Chat, SshProfile, User
from lembas.services import settings_store
from lembas.services.agent import hosts, policy
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent.base import Executor
from lembas.services.agent.policy import Limits
log = logging.getLogger(__name__)
@dataclass
class AgentContext:
"""The machine an agent chat acts on, and what it may do there."""
chat_id: str
label: str
project_dir: str
# The connection's id, carried so a runner can drop the project listing it
# has just invalidated. `index` is keyed on the connection and the
# directory, not on the chat -- two chats on one tree share a listing.
profile_id: str = ""
mode: str = policy.MODE_MANUAL
allow: tuple[str, ...] = ()
deny: tuple[str, ...] = ()
limits: Limits = field(default_factory=Limits)
# Per-command bounds, from the instance settings.
timeout: float = 60.0
max_timeout: float = 600.0
max_output: int = 64 * 1024
# The decrypted credential. Held here and nowhere else, and cleared by
# `generation` when the reply ends -- a finished Generation lingers five
# minutes so late followers get the final frames, and a private key should
# not linger with it.
spec: dict[str, Any] = field(default_factory=dict)
# Set only on the per-call copy handed to a runner whose call a person has
# just allowed. The runners re-check the mode as a backstop, and without
# this they would refuse the very thing that was approved -- the mode says
# "ask", and asking is exactly what happened.
approved: bool = False
# Absolute paths this reply has read. `file_edit` refuses a file that is not
# in here, because a patch written from memory against a file the model has
# not looked at is how a rewrite silently loses somebody's work.
#
# Here rather than on `Generation` for two reasons. Runners never see a
# Generation -- they get a `ToolContext`, which is a session-free snapshot
# precisely so nothing in a tool holds live state -- and a read path is a
# fact about the machine, which is what this class is.
#
# It is **shared with the approved copy**: `as_approved` is
# `dataclasses.replace`, which copies field references, so a path read
# through an approved call is visible here. That is wanted and is not
# obvious, so there is a test for it.
#
# It resets each reply, and that is correct rather than a limitation.
# `Message.tool_calls_json` is deliberately never replayed as context, so on
# the next turn the model does not have the file's contents either --
# requiring a re-read in the reply that edits is asking for something it
# needs anyway.
read_paths: set[str] = field(default_factory=set)
# The plan currently in force, seeded from `chat.plan_message_id` when this
# is resolved. Mutable and read/written in place by `plan_update`, for a
# reason that is not obvious: a runner cannot write the message row --
# `_persist` is the single writer -- so it returns the merged plan on its
# event and the loop carries it. Two updates in one reply would then both
# read the same stale plan from the database and the second would lose the
# first. This snapshot is what they actually merge into.
plan: dict[str, Any] = field(default_factory=dict)
# Whether commands may run detached. When off, `shell_run` is byte-for-byte
# what it always was and the `job_*` tools are not offered -- a command that
# times out is killed, as before. When on, a command can be launched in the
# background (or converted to one when it times out) and the model gets the
# tools to check on it. `on_timeout` is the sub-switch for the auto-convert.
background: bool = False
background_on_timeout: bool = True
# Whether a finished job wakes the model on its own, rather than only being
# seen when it next runs. Read by the wording here and by the watcher.
background_notify: bool = True
# Most jobs watched at once. A watcher is a periodic reconnect, so this is a
# real resource; past it a job still runs but is not watched or woken for.
background_max_jobs: int = 5
def executor(self) -> Executor:
return ssh_service.SshExecutor(self.spec, self.project_dir)
def clear(self) -> None:
self.spec = {}
def as_approved(self) -> AgentContext:
"""A copy of this context for one call a person has allowed."""
return replace(self, approved=True)
def _plan_of(db: DBSession, chat: Chat) -> dict[str, Any]:
"""The plan this chat is working to, or an empty dict.
One `db.get` by primary key -- the column exists to avoid a scan for "the
newest message carrying a plan", because this runs while a request is
waiting. The id is validated here rather than constrained in the schema, for
the reason the column's comment gives.
"""
from lembas.db.models import Message
from lembas.services import plans
if not chat.plan_message_id:
return {}
message = db.get(Message, chat.plan_message_id)
if message is None or message.chat_id != chat.id:
return {}
return plans.normalise(message.plan_json)
def profile_for(db: DBSession, chat: Chat, user: User | None) -> SshProfile | None:
"""The connection this chat is pointed at, if it is still usable.
Ownership is re-checked here rather than trusted from when the chat was
created: a profile can be deleted, disabled, or moved to a host whose key
has not been confirmed since, and any of those should stop the chat acting
rather than be discovered at the first command.
"""
if chat is None or chat.kind != KIND_AGENT or not chat.ssh_profile_id:
return None
profile = db.get(SshProfile, chat.ssh_profile_id)
if profile is None or not profile.enabled:
return None
if user is not None and profile.owner_id != user.id:
return None
# A row can predate a setting, so this is asked here rather than trusted
# from when the profile was saved: an administrator moving the switch to
# `off` has to stop the chats already pointed at loopback, not only the next
# one somebody tries to create. See services/agent/hosts.py.
if not hosts.usable(db, profile):
return None
return profile
def _allow_for(chat: Chat) -> tuple[str, ...]:
"""Imported inside `resolve` rather than at module scope.
`services/tools.py` imports this module's `resolve`, so a top-level import
back the other way is a cycle.
"""
from lembas.services import tools as tools_service
return tools_service.scoped_allow(chat)
def refresh(db: DBSession, agent: AgentContext) -> AgentContext:
"""Re-read the two things a person can change while a reply is running.
The mode and the chat's own allow list, and nothing else. Everything else on
the context is fixed for the life of a chat (the connection, the directory)
or is an instance setting nobody is editing mid-reply.
Called once per round rather than once per reply. The reply-long snapshot it
replaces made both controls do nothing until the next turn: switching to
Auto during a long agent reply went on asking about every call, and
"Always allow this" was accepted, written to the row, and then ignored for
the rest of the reply that had just asked. Both look exactly like a control
that does not work, because for that reply they were.
Once per *round* and not more often, because a round's calls are authorised
together: what is already queued was decided under the mode that was in
force when it was queued, and switching to Auto must not retroactively
approve it. Mutated in place -- `as_approved` copies field references, so a
replacement here would leave the approved copy of this round pointing at the
old one.
"""
chat = db.get(Chat, agent.chat_id)
if chat is None:
return agent
agent.mode = chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL
instance = settings_store.agents(db)
agent.allow = (*(instance.get("allow_default") or ()), *_allow_for(chat))
return agent
def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None:
"""This chat's agent setup, or None if it has none it can use.
None is the answer to every "no": not an agent chat, the feature switched
off, the connection gone or disabled, SSH not installed. Each of those means
the agent tools are not offered at all, which is better than offering a tool
that fails on its first call.
A profile whose host key has never been confirmed is deliberately *not* one
of them. The tools are offered and the failure is explicit, because "check
the connection and accept its fingerprint" is a thing the reader can act on,
while a silently missing tool is not.
"""
profile = profile_for(db, chat, user)
if profile is None:
return None
values = settings_store.agents(db)
if not values.get("enabled"):
return None
if ssh_service.available():
return None
return AgentContext(
chat_id=chat.id,
label=profile.label,
plan=_plan_of(db, chat),
project_dir=chat.project_dir or profile.default_dir or "",
profile_id=profile.id,
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
# The instance's list, plus whatever this chat's reader has said
# "always" to on a card. Never the other way round for the deny list:
# a chat cannot un-deny anything, and `decide` consults deny first
# regardless.
allow=(*(values.get("allow_default") or ()), *_allow_for(chat)),
deny=tuple(values.get("deny_default") or ()),
limits=Limits(
steps=int(values.get("max_steps") or 200),
wall_seconds=float(values.get("max_wall_seconds") or 900),
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
# `or 0` would turn a deliberate 0 into the default, and 0 is how an
# administrator says "no ceiling". `agents()` has already clamped it.
completion_tokens=int(values.get("max_completion_tokens", 200_000) or 0),
),
timeout=float(values.get("default_timeout") or 60),
max_timeout=float(values.get("max_timeout") or 600),
max_output=int(values.get("max_output_bytes") or 64 * 1024),
background=bool(values.get("background_enabled")),
background_on_timeout=bool(values.get("background_on_timeout", True)),
background_notify=bool(values.get("background_notify", True)),
background_max_jobs=int(values.get("background_max_jobs") or 5),
spec=ssh_service.spec_from(profile),
)
__all__ = ["AgentContext", "profile_for", "resolve"]