"""The terminal and the canvas before a chat exists. Chats are created lazily and that rule is kept: a draft is not a chat and never becomes one. What it does is hold an id, three facts and a tab strip, so the panels can work on the screen where you are still deciding which machine to work on -- and hand all of it over when the first prompt creates the real chat. The security-shaped tests here are the two refusals. `canvas._load_file` authorises with `attachment.chat_id != chat.id`, and a draft upload is stored with `chat_id=None`; a draft whose chat carried no id would make that comparison `None != None`, which is False. """ from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import select from lembas.db.models import KIND_AGENT, Chat, Connection, Model, SshProfile, User from lembas.services import settings_store from lembas.services.agent import draft as draft_service from lembas.services.agent import terminal as terminal_service # Stands up something real -- see the `slow` marker in pyproject.toml. pytestmark = pytest.mark.slow @pytest.fixture(autouse=True) def _clean(): draft_service.clear() yield draft_service.clear() @pytest.fixture def target(db, registered): settings_store.update( db, {"enabled": True, "terminal_enabled": True}, key=settings_store.AGENTS ) user = db.scalar(select(User)) profile = SshProfile( owner_id=user.id, name="Box", host="127.0.0.1", port=1, username="nobody", host_key="ssh-ed25519 AAAA", host_fingerprint="SHA256:x", default_dir="/srv/project", ) connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="") db.add_all([profile, connection]) db.commit() db.add(Model(connection_id=connection.id, model_id="m", capabilities_json={"tools": True})) db.commit() return user, profile # --- The id -------------------------------------------------------------------- def test_the_same_target_is_the_same_draft(): """Derived rather than invented, so coming back to the new-chat screen finds the shell already running there instead of quietly opening a second.""" one = draft_service.remember("u1", "p1", "/srv/x") two = draft_service.remember("u1", "p1", "/srv/x") assert one.id == two.id assert draft_service.is_draft(one.id) def test_a_different_directory_or_owner_is_a_different_draft(): base = draft_service.remember("u1", "p1", "/srv/x").id assert draft_service.remember("u1", "p1", "/srv/y").id != base assert draft_service.remember("u1", "p2", "/srv/x").id != base # Two people pointed at the same directory of the same connection would # otherwise share a *shell*, and one chat one shell is scoped to a person. assert draft_service.remember("u2", "p1", "/srv/x").id != base def test_a_draft_id_cannot_be_confused_with_a_chat_id(): """`Chat.id` is 32 hex characters. Nothing here can collide with one.""" assert not draft_service.is_draft("a" * 32) assert not draft_service.is_draft("") def test_somebody_elses_draft_is_not_readable(): """The id is a hash of the owner, so it cannot be guessed -- but that is not an authorisation, and the next caller might build the id differently.""" made = draft_service.remember("u1", "p1", "/srv/x") assert draft_service.get(made.id, "u1") is not None assert draft_service.get(made.id, "u2") is None # --- The transient chat -------------------------------------------------------- def test_the_transient_chat_carries_what_the_panels_read(): """`agent_ready`, `_executor`, `_load_agent` and `agent_session.resolve` read exactly these four, and none of them queries or writes the row -- which is why none of them had to learn what a draft is.""" made = draft_service.remember("u1", "p1", "/srv/x") chat = draft_service.as_chat(made) assert chat.user_id == "u1" assert chat.kind == KIND_AGENT assert chat.ssh_profile_id == "p1" assert chat.project_dir == "/srv/x" assert chat.canvas_json == {} def test_the_transient_chat_has_an_id_and_is_never_saved(db): """The id matters: `_load_file` compares it against an attachment's, and a draft upload has `chat_id=None`. `None != None` is False.""" chat = draft_service.as_chat(draft_service.remember("u1", "p1", "/srv/x")) assert chat.id, "a column default is applied at flush, and this is never flushed" assert db.get(Chat, chat.id) is None, "and it must not have reached the database" # --- The refusals -------------------------------------------------------------- def test_the_two_sources_that_need_a_row_are_refused_by_name(): """Stated rather than left to fall out of an id comparison. `scratch` would write a row keyed on a chat that does not exist; `file` is the hole.""" assert draft_service.refuses("scratch") assert draft_service.refuses("file") assert not draft_service.refuses("agent") assert not draft_service.refuses("note") def test_opening_a_refused_source_says_so_rather_than_failing(client, db, target): user, profile = target made = draft_service.remember(user.id, profile.id, "/srv/project") response = client.post( f"/api/chats/{made.id}/canvas/tabs", data={"key": f"scratch:{made.id}"} ) assert response.status_code == 200 assert "once this chat exists" in response.text assert db.query(Chat).count() == 0, "and no row was created on the way" # --- Adoption ------------------------------------------------------------------ def test_sending_the_first_prompt_adopts_the_shell(client, db, target, monkeypatch): """The shell opened while deciding becomes the chat's shell, scrollback and all. A re-key, not a reconnect: the browser navigates after `start_chat` and attaches to the session now living under the real id.""" user, profile = target made = draft_service.remember(user.id, profile.id, "/srv/project") session = _fake_session(made.id, user.id, profile.id, "/srv/project") terminal_service._SESSIONS[made.id] = session try: client.post( "/api/chats/start", data={ "content": "Do the thing.", "kind": "agent", "ssh_profile_id": profile.id, "project_dir": "/srv/project", "model_id": "m", "draft_id": made.id, }, ) chat = db.scalar(select(Chat)) assert terminal_service._SESSIONS.get(chat.id) is session assert made.id not in terminal_service._SESSIONS # Both, or `close_for_profile` and the reaper -- which pop by the field # rather than by the key -- would leave a dead session findable. assert session.chat_id == chat.id finally: terminal_service._SESSIONS.clear() def test_a_shell_on_a_different_target_is_not_transplanted(client, db, target): """`_new_chat` settles the directory last: an empty one falls back to the connection's own. A shell opened elsewhere belongs to the draft it was opened under, and is reaped on idle rather than moved.""" user, profile = target made = draft_service.remember(user.id, profile.id, "/somewhere/else") session = _fake_session(made.id, user.id, profile.id, "/somewhere/else") terminal_service._SESSIONS[made.id] = session try: client.post( "/api/chats/start", data={ "content": "Do the thing.", "kind": "agent", "ssh_profile_id": profile.id, "project_dir": "/srv/project", "model_id": "m", "draft_id": made.id, }, ) chat = db.scalar(select(Chat)) assert chat.id not in terminal_service._SESSIONS assert session.chat_id == made.id finally: terminal_service._SESSIONS.clear() def test_open_tabs_are_carried_onto_the_chat(client, db, target): user, profile = target made = draft_service.remember(user.id, profile.id, "/srv/project") made.canvas_json = { "tabs": [ {"key": "agent:/srv/project/main.py", "title": "main.py", "source": "agent"}, # Dropped rather than carried across to fail on first click. {"key": "scratch:whatever", "title": "Scratch", "source": "scratch"}, ], "active": "agent:/srv/project/main.py", } client.post( "/api/chats/start", data={ "content": "Do the thing.", "kind": "agent", "ssh_profile_id": profile.id, "project_dir": "/srv/project", "model_id": "m", "draft_id": made.id, }, ) chat = db.scalar(select(Chat)) assert [t["key"] for t in chat.canvas_json["tabs"]] == ["agent:/srv/project/main.py"] assert draft_service.get(made.id, user.id) is None, "and the draft is done with" def test_a_draft_belonging_to_somebody_else_adopts_nothing(client, db, target): user, profile = target theirs = draft_service.remember("someone-else", profile.id, "/srv/project") theirs.canvas_json = {"tabs": [{"key": "agent:/etc/shadow", "source": "agent"}]} client.post( "/api/chats/start", data={ "content": "Do the thing.", "kind": "agent", "ssh_profile_id": profile.id, "project_dir": "/srv/project", "model_id": "m", "draft_id": theirs.id, }, ) chat = db.scalar(select(Chat)) assert not (chat.canvas_json or {}).get("tabs") # --- The screen ---------------------------------------------------------------- def test_the_new_chat_screen_offers_both_panels(client: TestClient, target): """The decision this reverses: they used to require a chat, which meant they were absent on the one screen where you are choosing a machine.""" html = client.get("/chat").text assert "data-terminal" in html assert "data-canvas" in html assert "js/draft.js" in html def test_the_draft_route_answers_with_a_stable_id(client: TestClient, target): _user, profile = target first = client.get(f"/api/agents/{profile.id}/draft?dir=/srv/project").json() again = client.get(f"/api/agents/{profile.id}/draft?dir=/srv/project").json() assert first["id"] == again["id"] assert draft_service.is_draft(first["id"]) assert first["dir"] == "/srv/project" def test_the_draft_route_refuses_somebody_elses_connection(client: TestClient, db, target): _user, profile = target stranger = User(email="s@x.test", name="S", password_hash="x") db.add(stranger) db.commit() profile.owner_id = stranger.id db.commit() assert client.get(f"/api/agents/{profile.id}/draft").status_code == 404 class _FakeSession: def __init__(self, chat_id, owner_id, profile_id, project_dir): self.chat_id = chat_id self.owner_id = owner_id self.profile_id = profile_id self.project_dir = project_dir self.closed = False def _fake_session(chat_id, owner_id, profile_id, project_dir): return _FakeSession(chat_id, owner_id, profile_id, project_dir) # --- Against a real machine ---------------------------------------------------- asyncssh = pytest.importorskip("asyncssh") async def test_a_draft_canvas_opens_and_saves_a_project_file(db, registered, tmp_path): """The point of the whole thing: look around the machine, and edit something on it, before committing to a conversation about it. Against a real sshd and through `canvas_service` directly rather than the TestClient -- the client drives the app on another thread, and the server here is bound to this test's loop. What is being claimed is that a *transient* chat drives the same SFTP path a real one does, and that is what this exercises. """ from lembas.services import canvas as canvas_service from lembas.services.agent import ssh as ssh_service from tests.test_canvas_ssh import _Server project = tmp_path / "project" project.mkdir() (project / "main.py").write_text("print('before')\n") server = await asyncssh.create_server( _Server, "127.0.0.1", 0, server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")], # SFTP only: the canvas never goes through a shell -- a path is a path. sftp_factory=True, ) port = next(iter(server.sockets)).getsockname()[1] line, fingerprint = await ssh_service.capture_host_key("127.0.0.1", port) try: settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS) user = db.scalar(select(User)) profile = SshProfile( owner_id=user.id, name="Box", host="127.0.0.1", port=port, username="tester", host_key=line, host_fingerprint=fingerprint, default_dir=str(project), ) db.add(profile) db.commit() made = draft_service.remember(user.id, profile.id, str(project)) chat = draft_service.as_chat(made) key = f"agent:{project}/main.py" doc = await canvas_service.load(db, user, chat, key) assert "before" in doc.text await canvas_service.save(db, user, chat, key, "print('after')\n", doc.revision) assert (project / "main.py").read_text() == "print('after')\n" # And none of it reached the database. assert db.query(Chat).count() == 0 finally: server.close() await server.wait_closed()