a63723713f
The terminal and the canvas both needed a Chat, so they were missing from the one screen where you are choosing which machine to work on. A draft is the smallest thing that fixes it: an id, and the three facts behind it. The trick is that a draft resolves to a *transient* Chat -- constructed, never added to a session. `canvas.agent_ready`, `_executor`, `_load_agent`, `_save_agent` and `agent_session.resolve` read exactly four attributes between them and none of them queries or writes the row, so all of it works unchanged and nothing had to learn what a draft is. Proven against a real sshd rather than a stub: a transient chat opens and saves a project file over the same SFTP path a real one uses, and the database stays empty throughout. Chats are still created lazily. A draft is not a chat and never becomes one; when the first prompt makes the real one, the shell is re-keyed into it and the open tabs are copied across. `terminal.rekey` moves the registry key *and* `session.chat_id`, because close_for_profile, close_for_owner and the reaper all pop by the field -- a stale one would leave a dead session that `get` keeps handing out. The shell is only adopted when its profile and directory match the chat as finally resolved, since `_new_chat` settles an empty directory to the connection's own; otherwise it is left alone rather than transplanted onto a chat that says it runs elsewhere. Two canvas sources are refused on a draft, by name, and one of them is a hole rather than an inconvenience. `_load_file` authorises with `attachment.chat_id != chat.id`, and an upload made on the new-chat screen is stored with `chat_id=None` -- so a draft whose chat carried no id would make that comparison `None != None`, which is False, and open every unclaimed attachment its owner has. `as_chat` does set an id, so it already fails; the refusal is stated anyway, because a guarantee that lives in an id-shaped coincidence is one the next change breaks without noticing. Adoption needed almost no JavaScript: start_chat already answers with HX-Redirect, so the page reloads and the canvas adopts by construction while the terminal reconnects to the re-keyed session and replays its scrollback -- the "a reload is indistinguishable from a second tab" property working for us. What re-points them mid-screen is a `lembas:agent-target` event, dispatched from `setDir` and the connection select because assigning to a hidden field's value fires nothing on its own. Driven under a DOM stub before committing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
361 lines
13 KiB
Python
361 lines
13 KiB
Python
"""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
|
|
|
|
|
|
@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()
|