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:
@@ -0,0 +1,91 @@
|
||||
"""Whether agent chats exist here at all, and what they may spend.
|
||||
|
||||
An administrator's half of the feature. The other half -- which machines, whose
|
||||
credentials -- belongs to whoever owns them and lives at `/agents`.
|
||||
|
||||
Nothing here is about isolation, because there is none to configure: commands
|
||||
run on a host somebody chose, and its containment is that host's. The settings
|
||||
are budgets, and the two lists that decide what a mode asks about.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/agents", tags=["admin-agents"])
|
||||
|
||||
|
||||
def _lines(text: str) -> list[str]:
|
||||
"""One pattern per line, blanks dropped."""
|
||||
return [line.strip() for line in (text or "").splitlines() if line.strip()]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
|
||||
values = settings_store.agents(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/agents.html",
|
||||
{
|
||||
"values": values,
|
||||
"allow_text": "\n".join(values.get("allow_default") or []),
|
||||
"deny_text": "\n".join(values.get("deny_default") or []),
|
||||
"problem": ssh_service.available(),
|
||||
"profile_count": db.scalar(select(func.count()).select_from(SshProfile)) or 0,
|
||||
"modes": [(m, policy.MODE_LABELS[m], policy.MODE_HINTS[m]) for m in policy.MODES],
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_agents(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
default_timeout: int = Form(60),
|
||||
max_timeout: int = Form(600),
|
||||
max_output_bytes: int = Form(64 * 1024),
|
||||
max_steps: int = Form(40),
|
||||
max_wall_seconds: int = Form(900),
|
||||
max_total_output_bytes: int = Form(1024 * 1024),
|
||||
approval_timeout: int = Form(900),
|
||||
allow_default: str = Form(""),
|
||||
deny_default: str = Form(""),
|
||||
ask_free_text: bool = Form(False),
|
||||
) -> Response:
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Clamped here as well as on read. A number with no bound is a way
|
||||
# to break the instance from a form, which is the same reasoning
|
||||
# the search settings carry.
|
||||
"default_timeout": min(max(default_timeout, 1), 3600),
|
||||
"max_timeout": min(max(max_timeout, 1), 3600),
|
||||
"max_output_bytes": min(max(max_output_bytes, 1024), 1024 * 1024),
|
||||
"max_steps": min(max(max_steps, 1), 200),
|
||||
"max_wall_seconds": min(max(max_wall_seconds, 30), 7200),
|
||||
"max_total_output_bytes": min(max(max_total_output_bytes, 4096), 8 * 1024 * 1024),
|
||||
"approval_timeout": min(max(approval_timeout, 60), 3600),
|
||||
"allow_default": _lines(allow_default),
|
||||
"deny_default": _lines(deny_default),
|
||||
"ask_free_text": ask_free_text,
|
||||
},
|
||||
key=settings_store.AGENTS,
|
||||
)
|
||||
log.info("agent execution %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
Reference in New Issue
Block a user