4ced049ff8
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>
120 lines
3.7 KiB
Python
120 lines
3.7 KiB
Python
"""What an agent chat needs from the machine it acts on.
|
|
|
|
One interface, currently one implementation. It exists as an interface anyway
|
|
because the *snapshot* is the load-bearing part: a generation outlives the
|
|
request that started it, so everything a runner needs -- the host, the decrypted
|
|
credential, the mode, the project directory -- has to be read while the session
|
|
is open and carried, not looked up later. That is the same reason `Endpoint` is
|
|
a frozen copy of a `Connection` and `ToolContext` holds an owner id rather than
|
|
a `User`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Protocol
|
|
|
|
# What a command may weigh before it is cut off. Per call; the reply also has a
|
|
# total, in policy.Limits.
|
|
DEFAULT_MAX_BYTES = 64 * 1024
|
|
DEFAULT_TIMEOUT = 60.0
|
|
|
|
# Terminal escape sequences, stripped from anything a command produced. They are
|
|
# inert in escaped HTML, but this text also re-enters the model's context, where
|
|
# they are a known way of hiding instructions, and it may end up in a log a
|
|
# person later cats, where they hijack the terminal.
|
|
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExecRequest:
|
|
"""One command to run."""
|
|
|
|
command: str
|
|
cwd: str = ""
|
|
timeout: float = DEFAULT_TIMEOUT
|
|
max_bytes: int = DEFAULT_MAX_BYTES
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExecResult:
|
|
"""What running it produced.
|
|
|
|
`output` is stdout and stderr interleaved, because a shell transcript is
|
|
what the model needs to read and separating them loses the ordering that
|
|
makes an error make sense.
|
|
"""
|
|
|
|
exit_status: int
|
|
output: str
|
|
truncated: bool = False
|
|
timed_out: bool = False
|
|
duration_ms: int = 0
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.exit_status == 0 and not self.timed_out
|
|
|
|
|
|
class ExecError(Exception):
|
|
"""Nothing could be run at all: the host refused, or the credential did.
|
|
|
|
Distinct from a command that ran and failed -- that is an `ExecResult` with
|
|
a non-zero status, which the model should read and react to. This is the
|
|
reply not being able to act, which is a message for a person.
|
|
"""
|
|
|
|
def __init__(self, message: str) -> None:
|
|
super().__init__(message)
|
|
self.message = message
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Target:
|
|
"""A machine an agent chat acts on, read while the session was open.
|
|
|
|
Holds the decrypted credential and nothing else does. `generation` clears it
|
|
when the reply ends, because a finished `Generation` lingers for five
|
|
minutes so late followers get the final frames, and a private key should not
|
|
linger with it.
|
|
"""
|
|
|
|
kind: str
|
|
label: str
|
|
project_dir: str = ""
|
|
spec: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
class Executor(Protocol):
|
|
"""How a target is acted on. See `ssh.py`; there is no local variant."""
|
|
|
|
async def run(self, request: ExecRequest) -> ExecResult: ...
|
|
|
|
async def read_file(self, path: str, *, max_bytes: int) -> str: ...
|
|
|
|
async def write_file(self, path: str, text: str) -> int: ...
|
|
|
|
async def list_dir(self, path: str) -> list[str]: ...
|
|
|
|
|
|
def clean_output(data: bytes | str, *, limit: int) -> tuple[str, bool]:
|
|
"""Decode, strip escape sequences, and cap. Returns (text, truncated)."""
|
|
text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data
|
|
text = _ANSI.sub("", text)
|
|
if len(text) <= limit:
|
|
return text, False
|
|
return text[:limit].rstrip() + "\n… (truncated)", True
|
|
|
|
|
|
__all__ = [
|
|
"DEFAULT_MAX_BYTES",
|
|
"DEFAULT_TIMEOUT",
|
|
"ExecError",
|
|
"ExecRequest",
|
|
"ExecResult",
|
|
"Executor",
|
|
"Target",
|
|
"clean_output",
|
|
]
|