47791a88c7
A real shell on the chat's own connection, opened and closed like the inspector and never beside it. The modes govern the model; what a person types is theirs, since they hold the credential and could open the same shell with an ssh client. The model cannot see the panel -- a button copies the output you choose into the composer. The session outlives the socket: closing the panel leaves a build running, and coming back reattaches with the scrollback. Two tabs share one shell and the smaller window decides the size. It ends on an idle timeout, on deleting the chat, on disabling, moving or deleting the connection, and on a restart -- which says why rather than quietly opening a fresh shell that has lost the working directory. The nginx template's `Connection ""` is right for SSE and fails every WebSocket handshake, so `location /` now uses a `map $http_upgrade`; update.sh grows a drift check for it, because the only symptom on a stale vhost is a panel that cannot connect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""Acting on a machine over SSH.
|
|
|
|
Driven against a real asyncssh server on 127.0.0.1 with a generated host key, so
|
|
nothing here touches an outside network and the host-key path is exercised for
|
|
real rather than mocked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from lembas.services.agent import ssh
|
|
from lembas.services.agent.base import ExecError, ExecRequest
|
|
|
|
asyncssh = pytest.importorskip("asyncssh")
|
|
|
|
|
|
# --- A server to talk to -------------------------------------------------------
|
|
def _generate_key():
|
|
return asyncssh.generate_private_key("ssh-ed25519")
|
|
|
|
|
|
class _Server(asyncssh.SSHServer):
|
|
def begin_auth(self, username: str) -> bool:
|
|
return False # no authentication wanted; every login succeeds
|
|
|
|
|
|
async def _handler(process):
|
|
"""A shell that understands just enough to be tested against."""
|
|
command = process.command or ""
|
|
if "sleep" in command:
|
|
await asyncio.sleep(5)
|
|
process.exit(0)
|
|
return
|
|
if "flood" in command:
|
|
process.stdout.write("x" * 200_000)
|
|
process.exit(0)
|
|
return
|
|
if "fail" in command:
|
|
process.stderr.write("it went wrong\n")
|
|
process.exit(3)
|
|
return
|
|
if "colour" in command:
|
|
process.stdout.write("\x1b[31mred\x1b[0m\n")
|
|
process.exit(0)
|
|
return
|
|
process.stdout.write(f"ran: {command}\n")
|
|
process.exit(0)
|
|
|
|
|
|
async def _start(host_key=None):
|
|
server = await asyncssh.create_server(
|
|
_Server,
|
|
"127.0.0.1",
|
|
0,
|
|
server_host_keys=[host_key or _generate_key()],
|
|
process_factory=_handler,
|
|
sftp_factory=True,
|
|
)
|
|
port = next(iter(server.sockets)).getsockname()[1]
|
|
return server, port
|
|
|
|
|
|
def _spec(port: int, host_key: str, **overrides) -> dict:
|
|
base = {
|
|
"id": "p1",
|
|
"label": "test box",
|
|
"host": "127.0.0.1",
|
|
"port": port,
|
|
"username": "tester",
|
|
"auth": "key",
|
|
"password": "",
|
|
"private_key": "",
|
|
"key_passphrase": "",
|
|
"host_key": host_key,
|
|
"connect_timeout": 5,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
@pytest.fixture
|
|
async def project(tmp_path):
|
|
"""A directory of its own.
|
|
|
|
Not `tmp_path` itself: the autouse database fixture points the data
|
|
directory there, so a listing would find lembas.db and the uploads folder.
|
|
"""
|
|
path = tmp_path / "project"
|
|
path.mkdir()
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
async def box(project):
|
|
"""A running server, its pinned host key line, and a project directory."""
|
|
key = _generate_key()
|
|
server, port = await _start(key)
|
|
line, fingerprint = await ssh.capture_host_key("127.0.0.1", port)
|
|
try:
|
|
yield {
|
|
"port": port,
|
|
"host_key": line,
|
|
"fingerprint": fingerprint,
|
|
"dir": str(project),
|
|
"server": server,
|
|
}
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
|
|
|
|
# --- The four defaults that must never be left to asyncssh --------------------
|
|
def test_the_dangerous_defaults_are_all_passed_explicitly():
|
|
"""Every LLeMbas user shares one unix account, so "whatever the account has
|
|
lying around" is never the right answer. This is the most important test in
|
|
the file and it needs no server at all."""
|
|
kwargs = ssh.connect_kwargs(_spec(22, "127.0.0.1 ssh-ed25519 AAAA\n"))
|
|
|
|
# Bytes, never None: None turns host key checking off entirely.
|
|
assert isinstance(kwargs["known_hosts"], bytes)
|
|
assert kwargs["known_hosts"] != b""
|
|
# Not left to load ~/.ssh/id_*, which could be another person's key.
|
|
assert kwargs["client_keys"] == []
|
|
# Not left to read ~/.ssh/config, where ProxyCommand could redirect us.
|
|
assert kwargs["config"] is None
|
|
# Not left to use $SSH_AUTH_SOCK.
|
|
assert kwargs["agent_path"] is None
|
|
|
|
|
|
def test_a_profile_with_no_confirmed_host_key_refuses_to_connect():
|
|
with pytest.raises(ExecError, match="host key has not been confirmed"):
|
|
ssh.connect_kwargs(_spec(22, ""))
|
|
|
|
|
|
def test_a_password_profile_sends_a_password_and_no_keys():
|
|
kwargs = ssh.connect_kwargs(
|
|
_spec(22, "h ssh-ed25519 AAAA\n", auth="password", password="hunter2")
|
|
)
|
|
assert kwargs["password"] == "hunter2"
|
|
assert kwargs["client_keys"] == []
|
|
|
|
|
|
def test_a_key_profile_sends_no_password():
|
|
kwargs = ssh.connect_kwargs(_spec(22, "h ssh-ed25519 AAAA\n", password="stale"))
|
|
assert kwargs["password"] is None
|
|
|
|
|
|
# --- Trust on first use --------------------------------------------------------
|
|
async def test_the_first_look_captures_a_key_and_a_fingerprint(box):
|
|
assert box["host_key"].startswith(f"[127.0.0.1]:{box['port']} ssh-ed25519 ")
|
|
assert box["fingerprint"].startswith("SHA256:")
|
|
|
|
|
|
async def test_capturing_a_key_offers_no_credential():
|
|
"""get_server_host_key completes the key exchange and stops, which is what
|
|
makes accepting a fingerprint from a button safe: nothing is sent to a host
|
|
that has not been accepted yet.
|
|
|
|
The server here records every authentication attempt, so an empty list is
|
|
evidence rather than an absence of it.
|
|
"""
|
|
attempts: list[str] = []
|
|
|
|
class Watchful(asyncssh.SSHServer):
|
|
def begin_auth(self, username: str) -> bool:
|
|
attempts.append(username)
|
|
return False
|
|
|
|
server = await asyncssh.create_server(
|
|
Watchful, "127.0.0.1", 0, server_host_keys=[_generate_key()]
|
|
)
|
|
port = next(iter(server.sockets)).getsockname()[1]
|
|
try:
|
|
line, fingerprint = await ssh.capture_host_key("127.0.0.1", port)
|
|
assert line and fingerprint.startswith("SHA256:")
|
|
assert attempts == [], "a host not yet accepted was offered a username"
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
|
|
|
|
async def test_a_host_that_answers_with_a_different_key_is_refused(box):
|
|
"""The pinned key is the whole of the protection. A host presenting another
|
|
one is either rebuilt or is not the host."""
|
|
other_line, _ = await ssh.capture_host_key("127.0.0.1", box["port"])
|
|
wrong = other_line.rsplit(" ", 1)[0] + " " + "A" * 68 + "\n"
|
|
|
|
executor = ssh.SshExecutor(_spec(box["port"], wrong), box["dir"])
|
|
with pytest.raises(ExecError):
|
|
await executor.run(ExecRequest(command="echo hi", timeout=5))
|
|
|
|
|
|
async def test_an_unreachable_host_says_so():
|
|
with pytest.raises(ExecError, match="Could not reach"):
|
|
await ssh.capture_host_key("127.0.0.1", 1, timeout=3)
|
|
|
|
|
|
# --- Running commands ----------------------------------------------------------
|
|
async def test_a_command_runs_and_comes_back(box):
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
|
|
|
assert result.ok
|
|
assert result.exit_status == 0
|
|
assert "echo hi" in result.output
|
|
|
|
|
|
async def test_the_working_directory_is_set_and_never_spliced_in(box):
|
|
"""Every command is a fresh shell, so `cd` cannot carry between calls. The
|
|
directory is quoted, because `cwd` may come from the model."""
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), "/tmp/a dir")
|
|
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
|
assert "cd '/tmp/a dir' && echo hi" in result.output
|
|
|
|
|
|
async def test_a_directory_with_a_quote_in_it_cannot_end_the_quoting(box):
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), "/tmp/it's; rm -rf /")
|
|
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
|
assert "'/tmp/it'\\''s; rm -rf /'" in result.output
|
|
|
|
|
|
async def test_a_failing_command_is_a_result_not_an_error(box):
|
|
"""A command that ran and failed is something the model should read and
|
|
react to. Only being unable to act at all is an ExecError."""
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
result = await executor.run(ExecRequest(command="fail", timeout=5))
|
|
|
|
assert result.exit_status == 3
|
|
assert result.ok is False
|
|
assert "it went wrong" in result.output, "stderr is interleaved, not dropped"
|
|
|
|
|
|
async def test_a_slow_command_times_out_and_says_so(box):
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
result = await executor.run(ExecRequest(command="sleep", timeout=1))
|
|
|
|
assert result.timed_out is True
|
|
assert result.ok is False
|
|
assert "was stopped" in result.output
|
|
|
|
|
|
async def test_a_flood_of_output_is_capped(box):
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
result = await executor.run(ExecRequest(command="flood", timeout=10, max_bytes=2000))
|
|
|
|
assert result.truncated is True
|
|
assert len(result.output) < 2200
|
|
assert result.output.endswith("(truncated)")
|
|
|
|
|
|
async def test_terminal_escapes_are_stripped(box):
|
|
"""Inert in escaped HTML, but this text re-enters the model's context, where
|
|
they are a known way to hide instructions -- and a log a person later cats."""
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
result = await executor.run(ExecRequest(command="colour", timeout=5))
|
|
|
|
assert "red" in result.output
|
|
assert "\x1b" not in result.output
|
|
|
|
|
|
# --- Files, over SFTP ----------------------------------------------------------
|
|
async def test_a_file_is_written_and_read_back(box):
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
|
|
written = await executor.write_file("hello.txt", "a mallorn tree\n")
|
|
assert written == len("a mallorn tree\n")
|
|
assert await executor.read_file("hello.txt") == "a mallorn tree\n"
|
|
|
|
|
|
async def test_a_relative_path_resolves_against_the_project_directory(box, project):
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
await executor.write_file("nested.txt", "here")
|
|
|
|
assert (project / "nested.txt").read_text() == "here"
|
|
|
|
|
|
async def test_a_missing_file_is_reported_plainly(box):
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
with pytest.raises(ExecError, match="no file at"):
|
|
await executor.read_file("nope.txt")
|
|
|
|
|
|
async def test_a_directory_lists(box, project):
|
|
(project / "one.txt").write_text("1")
|
|
(project / "two.txt").write_text("2")
|
|
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
assert await executor.list_dir() == ["one.txt", "two.txt"]
|
|
|
|
|
|
async def test_reading_a_file_is_capped(box, project):
|
|
(project / "big.txt").write_text("y" * 50_000)
|
|
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
|
|
|
text = await executor.read_file("big.txt", max_bytes=1000)
|
|
assert len(text) <= 1000
|
|
|
|
|
|
# --- The check a person presses ------------------------------------------------
|
|
async def test_check_reports_what_it_found(box):
|
|
found = await ssh.check(_spec(box["port"], box["host_key"]), box["dir"])
|
|
assert found["ok"] is True
|
|
assert "uname" in found["output"]
|
|
|
|
|
|
# --- The snapshot --------------------------------------------------------------
|
|
def test_spec_from_decrypts_the_credential_and_the_row_does_not_hold_it(db, user_id):
|
|
from lembas.db.models import SshProfile
|
|
from lembas.services.crypto import encrypt
|
|
|
|
profile = SshProfile(
|
|
owner_id=user_id,
|
|
name="box",
|
|
host="10.0.0.5",
|
|
username="root",
|
|
private_key_encrypted=encrypt("PRIVATE KEY MATERIAL"),
|
|
host_key="h ssh-ed25519 AAAA\n",
|
|
)
|
|
db.add(profile)
|
|
db.commit()
|
|
|
|
spec = ssh.spec_from(profile)
|
|
assert spec["private_key"] == "PRIVATE KEY MATERIAL"
|
|
assert "PRIVATE KEY MATERIAL" not in profile.private_key_encrypted
|