4643d1b584
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>
341 lines
13 KiB
Python
341 lines
13 KiB
Python
"""The socket behind the terminal panel.
|
|
|
|
A WebSocket rather than SSE, because SSE is one-directional and a terminal is
|
|
not: keystrokes have to go up, and an HTTP round trip per keypress is not a
|
|
terminal. It is the only WebSocket in LLeMbas, and it is worth saying what that
|
|
costs -- a cross-site page that could reach this endpoint would have a shell on
|
|
somebody's machine, not merely a copy of their chat. So there are two locks on
|
|
the door, and this module is mostly them.
|
|
|
|
**Where a refusal happens is load-bearing.** A browser tells a page nothing
|
|
about a handshake that *failed*: `new WebSocket()` fires `error` with no status
|
|
and no reason. So the socket is accepted first and the reason sent as a frame
|
|
for everything a person could act on -- no permission, the connection is
|
|
disabled, its host key was never confirmed -- and refused before accepting only
|
|
for the two cases where accepting is itself the risk.
|
|
|
|
It holds no database session. A dependency would keep one open for the hour a
|
|
shell sits at a prompt; `session_scope()` opens one for the authorisation and
|
|
closes it, exactly as `generation._run` does.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
from urllib.parse import urlsplit
|
|
|
|
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
|
|
|
|
from lembas.api.deps import Db, RequiredUser
|
|
from lembas.db.models import KIND_AGENT, Chat
|
|
from lembas.db.session import session_scope
|
|
from lembas.security import permissions
|
|
from lembas.security.sessions import COOKIE_NAME, resolve_session
|
|
from lembas.services import settings_store
|
|
from lembas.services.agent import draft as draft_service
|
|
from lembas.services.agent import session as agent_session
|
|
from lembas.services.agent import terminal as terminal_service
|
|
from lembas.services.agent.base import ExecError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/chats", tags=["terminal"])
|
|
|
|
# Nothing a keyboard produces is anywhere near this. Paste is the only thing
|
|
# that comes close, and a megabyte pasted into a shell is a mistake either way.
|
|
MAX_INPUT_BYTES = 256 * 1024
|
|
|
|
# 1008 is "policy violation", the closest thing the protocol has to "no".
|
|
CLOSE_POLICY = 1008
|
|
|
|
# What the far side is told when a shell ends, in words rather than a code.
|
|
CLOSED_WORDS = {
|
|
terminal_service.CLOSED_EXITED: "The shell exited.",
|
|
terminal_service.CLOSED_IDLE: "This terminal was closed after sitting idle.",
|
|
terminal_service.CLOSED_SHUTDOWN: "LLeMbas restarted, so this shell was closed.",
|
|
terminal_service.CLOSED_REVOKED: "The connection behind this terminal was closed.",
|
|
terminal_service.CLOSED_ERROR: "The connection to the machine was lost.",
|
|
}
|
|
|
|
|
|
def _same_origin(websocket: WebSocket) -> bool:
|
|
"""Whether this handshake came from a page served by this site.
|
|
|
|
Required, not merely checked when present. The session cookie is SameSite
|
|
Lax, which already withholds it from a handshake a foreign page starts, and
|
|
this is the belt to that brace -- an absent Origin is not a browser, and a
|
|
non-browser client has no business here.
|
|
"""
|
|
origin = websocket.headers.get("origin")
|
|
host = websocket.headers.get("host")
|
|
if not origin or not host:
|
|
return False
|
|
return urlsplit(origin).netloc.lower() == host.lower()
|
|
|
|
|
|
def _chat_or_draft(db, user, chat_id: str):
|
|
"""The chat this panel belongs to, real or still being decided.
|
|
|
|
A draft resolves to a transient `Chat` -- see services/agent/draft.py --
|
|
which is what lets the terminal open on the new-chat screen without
|
|
`_prepare` or `agent_session.resolve` learning that drafts exist.
|
|
"""
|
|
if draft_service.is_draft(chat_id):
|
|
draft = draft_service.get(chat_id, user.id)
|
|
return draft_service.as_chat(draft) if draft is not None else None
|
|
chat = db.get(Chat, chat_id)
|
|
return chat if chat is not None and chat.user_id == user.id else None
|
|
|
|
|
|
def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
|
|
"""Everything that has to be true, and what opening needs. One or the other.
|
|
|
|
Returns a message to show, or the arguments for `open_session`. The order is
|
|
the order somebody would ask the questions in, and every "no" is a sentence
|
|
rather than a silence.
|
|
"""
|
|
if not permissions.has(db, user, "agent.terminal"):
|
|
return "You do not have permission to open a terminal.", {}
|
|
|
|
chat = _chat_or_draft(db, user, chat_id)
|
|
if chat is None:
|
|
return "That chat no longer exists.", {}
|
|
if chat.kind != KIND_AGENT:
|
|
return "This is an ordinary chat, so it has no machine to open a shell on.", {}
|
|
|
|
values = settings_store.agents(db)
|
|
if not values.get("terminal_enabled", True):
|
|
return "The terminal is switched off on this instance.", {}
|
|
|
|
context = agent_session.resolve(db, chat, user)
|
|
if context is None:
|
|
return (
|
|
"This chat's connection is not usable: it may have been deleted, "
|
|
"disabled, or agent chats may be switched off here.",
|
|
{},
|
|
)
|
|
|
|
return "", {
|
|
"owner_id": user.id,
|
|
"profile_id": chat.ssh_profile_id or "",
|
|
"label": context.label,
|
|
"spec": context.spec,
|
|
"project_dir": context.project_dir,
|
|
"idle_timeout": float(values["terminal_idle_timeout"]),
|
|
"max_sessions": int(values["terminal_max_sessions"]),
|
|
"max_per_user": int(values["terminal_max_per_user"]),
|
|
"integrate": bool(values.get("terminal_integration", True)),
|
|
}
|
|
|
|
|
|
@router.websocket("/{chat_id}/terminal/ws")
|
|
async def terminal_socket(
|
|
websocket: WebSocket,
|
|
chat_id: str,
|
|
cols: int = 80,
|
|
rows: int = 24,
|
|
) -> None:
|
|
if not _same_origin(websocket):
|
|
await websocket.close(code=CLOSE_POLICY)
|
|
return
|
|
|
|
with session_scope() as db:
|
|
user = resolve_session(db, websocket.cookies.get(COOKIE_NAME))
|
|
if user is None:
|
|
await websocket.close(code=CLOSE_POLICY)
|
|
return
|
|
problem, opening = _prepare(db, user, chat_id)
|
|
owner_email = user.email
|
|
|
|
await websocket.accept()
|
|
if problem:
|
|
await _refuse(websocket, problem)
|
|
return
|
|
|
|
try:
|
|
session = await terminal_service.open_session(chat_id, cols=cols, rows=rows, **opening)
|
|
except ExecError as exc:
|
|
await _refuse(websocket, str(exc))
|
|
return
|
|
except Exception: # noqa: BLE001 - a failure here is one socket, not the app
|
|
log.exception("could not open a terminal for %s", owner_email)
|
|
await _refuse(websocket, "The shell could not be started.")
|
|
return
|
|
|
|
# Shaping a frame is this layer's job, not the session's; the session only
|
|
# knows it finished something. Reassigned per socket and harmless: every
|
|
# socket on this session would build the identical frame.
|
|
session.on_command = lambda found: session.announce(
|
|
json.dumps({"t": "command", "command": _command_frame(found)})
|
|
)
|
|
|
|
viewer = session.attach(cols, rows)
|
|
await websocket.send_text(
|
|
json.dumps(
|
|
{
|
|
"t": "ready",
|
|
"label": session.label,
|
|
"dir": session.project_dir,
|
|
"cols": session.cols,
|
|
"rows": session.rows,
|
|
# Two tabs share one shell, and a size neither of them chose is
|
|
# otherwise a mystery.
|
|
"shared": len(session.viewers) > 1,
|
|
# Whether this shell will tell us where commands begin and end,
|
|
# which is what the Copy and Send buttons are made of.
|
|
"integration": session.integration,
|
|
"last": _command_frame(session.latest()),
|
|
}
|
|
)
|
|
)
|
|
if viewer.snapshot:
|
|
await websocket.send_bytes(viewer.snapshot)
|
|
|
|
downward = asyncio.create_task(_to_browser(websocket, session, viewer))
|
|
upward = asyncio.create_task(_from_browser(websocket, session, viewer))
|
|
try:
|
|
await asyncio.wait({downward, upward}, return_when=asyncio.FIRST_COMPLETED)
|
|
finally:
|
|
for task in (downward, upward):
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await task
|
|
# The session is deliberately left running. Closing the panel, or
|
|
# navigating away, is not "I am finished with this machine" -- a build
|
|
# carries on and the scrollback is still there on the way back. The
|
|
# idle timeout is what eventually ends it.
|
|
session.detach(viewer)
|
|
|
|
|
|
@router.get("/{chat_id}/terminal/last")
|
|
async def last_command(db: Db, user: RequiredUser, chat_id: str) -> dict:
|
|
"""The last command and its output, rendered ready to paste.
|
|
|
|
The *server* renders the text, so Copy and Send are a fetch and a
|
|
clipboard write with no formatting logic in the browser -- and the block a
|
|
model eventually reads exists in exactly one place. The panel's own screen
|
|
buffer could not produce it anyway: it holds what is on screen, hard-wrapped
|
|
at the terminal's width, with no way to tell a wrap from a newline.
|
|
"""
|
|
if _chat_or_draft(db, user, chat_id) is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
|
if not permissions.has(db, user, "agent.terminal"):
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot open a terminal.")
|
|
|
|
session = terminal_service.get(chat_id)
|
|
found = session.latest() if session is not None else None
|
|
if session is None or found is None:
|
|
return {
|
|
"ok": False,
|
|
"message": "Nothing has been run in this shell yet."
|
|
if session is not None
|
|
else "This terminal is not open.",
|
|
}
|
|
|
|
return {
|
|
"ok": True,
|
|
"command": found.command,
|
|
"cwd": found.cwd,
|
|
"exit": found.exit_status,
|
|
"running": found.running,
|
|
"summary": found.summary(),
|
|
"text": found.as_text(label=session.label),
|
|
}
|
|
|
|
|
|
def _command_frame(found) -> dict | None:
|
|
"""A finished command, small enough to push at every viewer.
|
|
|
|
Tens of bytes, and deliberately *not* the output: a 64KB text frame would
|
|
compete with PTY bytes on the one path that has to stay responsive, and the
|
|
two buttons are pressed by a person, where a request is the natural shape.
|
|
"""
|
|
if found is None:
|
|
return None
|
|
return {
|
|
"seq": found.seq,
|
|
"command": found.command,
|
|
"cwd": found.cwd,
|
|
"exit": found.exit_status,
|
|
"running": found.running,
|
|
"ms": found.duration_ms,
|
|
"summary": found.summary(),
|
|
}
|
|
|
|
|
|
async def _to_browser(websocket: WebSocket, session, viewer) -> None:
|
|
"""Everything the shell says, plus the one frame that says it stopped."""
|
|
while True:
|
|
chunk = await viewer.queue.get()
|
|
if chunk is None:
|
|
reason = terminal_service.CLOSED_EXITED if viewer.dropped else session.closed_reason
|
|
payload = {"t": "closed", "reason": reason, "message": _words(reason)}
|
|
if viewer.dropped:
|
|
# Not the session's doing: this browser stopped reading and was
|
|
# disconnected so the others kept up. Reconnecting costs it
|
|
# nothing, because the scrollback is the state.
|
|
payload = {"t": "behind", "message": "Reconnecting: output arrived faster than "
|
|
"this window could draw it."}
|
|
with contextlib.suppress(Exception):
|
|
await websocket.send_text(json.dumps(payload))
|
|
return
|
|
# A string in the queue is a control frame that had to keep its place
|
|
# in the stream -- see `Session.announce`.
|
|
if isinstance(chunk, str):
|
|
await websocket.send_text(chunk)
|
|
continue
|
|
await websocket.send_bytes(chunk)
|
|
|
|
|
|
async def _from_browser(websocket: WebSocket, session, viewer) -> None:
|
|
"""Keystrokes as binary, everything else as JSON.
|
|
|
|
Binary for the hot path is what makes multi-byte characters safe: a read on
|
|
the far side lands mid-sequence often enough to matter, and decoding each
|
|
frame here would corrupt every boundary. Nothing decodes, so nothing splits.
|
|
"""
|
|
while True:
|
|
try:
|
|
message = await websocket.receive()
|
|
except WebSocketDisconnect:
|
|
return
|
|
if message["type"] == "websocket.disconnect":
|
|
return
|
|
|
|
data = message.get("bytes")
|
|
if data is not None:
|
|
if len(data) > MAX_INPUT_BYTES:
|
|
continue
|
|
await session.send(data)
|
|
continue
|
|
|
|
text = message.get("text")
|
|
if text:
|
|
_control(session, viewer, text)
|
|
|
|
|
|
def _control(session, viewer, text: str) -> None:
|
|
try:
|
|
payload = json.loads(text)
|
|
except ValueError:
|
|
return
|
|
if not isinstance(payload, dict) or payload.get("t") != "resize":
|
|
return
|
|
session.resize(viewer, payload.get("cols", 80), payload.get("rows", 24))
|
|
|
|
|
|
def _words(reason: str) -> str:
|
|
return CLOSED_WORDS.get(reason, "This terminal closed.")
|
|
|
|
|
|
async def _refuse(websocket: WebSocket, message: str) -> None:
|
|
"""Say why, then close. Sent as a frame because a browser cannot read a
|
|
rejected handshake -- the reason would be lost exactly when it is needed."""
|
|
with contextlib.suppress(Exception):
|
|
await websocket.send_text(json.dumps({"t": "error", "message": message}))
|
|
with contextlib.suppress(Exception):
|
|
await websocket.close()
|