A terminal panel beside an agent chat
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>
This commit is contained in:
@@ -25,6 +25,7 @@ from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
from lembas.services.agent import terminal as terminal_service
|
||||
from lembas.services.agent.base import ExecError
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.web.templating import render
|
||||
@@ -293,6 +294,9 @@ async def forget_host_key(request: Request, db: Db, user: RequiredUser, profile_
|
||||
profile = _profile(db, user, profile_id)
|
||||
profile.host_key = ""
|
||||
profile.host_fingerprint = ""
|
||||
# Un-trusting a host has to reach the shell already open on it, or the one
|
||||
# connection that matters is the one this does not touch.
|
||||
await terminal_service.close_for_profile(profile.id)
|
||||
db.commit()
|
||||
return render(request, "agents/_check.html", {"profile": profile, "forgotten": True})
|
||||
|
||||
@@ -301,6 +305,7 @@ async def forget_host_key(request: Request, db: Db, user: RequiredUser, profile_
|
||||
async def delete_profile(db: Db, user: RequiredUser, profile_id: str) -> Response:
|
||||
profile = _profile(db, user, profile_id)
|
||||
name = profile.name
|
||||
await terminal_service.close_for_profile(profile.id)
|
||||
db.delete(profile)
|
||||
db.commit()
|
||||
log.info("%s deleted ssh profile %s", user.email, name)
|
||||
@@ -342,6 +347,13 @@ async def update_profile(request: Request, db: Db, user: RequiredUser, profile_i
|
||||
profile.host_fingerprint = ""
|
||||
log.info("%s moved ssh profile %s; its host key was forgotten", user.email, profile.name)
|
||||
|
||||
# A shell already open holds its own connection and would not notice any of
|
||||
# this. `session.profile_for` re-checks the profile on every reply, so the
|
||||
# model stops at once; without the line below, "I disabled that connection"
|
||||
# would simply not be true of the terminal on screen.
|
||||
if not profile.enabled or not profile.host_key or (profile.host, profile.port) != before:
|
||||
await terminal_service.close_for_profile(profile.id)
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/agents/{profile.id}?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
|
||||
Reference in New Issue
Block a user