Phase 21: container terminal (web exec), local + agent (0.27.0)

Interactive shell into a compose-managed container over WebSocket + xterm.js,
opened from the container card on the stack Overview tab. Admin-only (non-admin
handshake rejected with 4403); only containers with the compose project label
are reachable.

- backend services/exec_service.py: create/start/resize exec + a shared
  bidirectional pump_exec (recv/sendall on sock._sock, executor thread,
  resize control frames, exit-code frame).
- routers/ws.py: _authorize_admin + /ws/exec/{container_id} and the
  /ws/agent-exec/{agent_id}/{container_id} proxy (forwards BOTH directions).
- agent_app.py: /agent/ws/exec/{container_id}.
- frontend: @xterm/xterm + @xterm/addon-fit; ContainerTerminal modal (shell
  picker, fit/resize, exit/error handling) + a Terminal button on ContainerCard.

Live-verified (TestClient): local happy/exit/guard/4403/4401, agent happy/4401,
proxy bidirectional round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-09 12:53:21 +00:00
co-authored by Claude Opus 4.8
parent b44a5b9f86
commit be3568274f
10 changed files with 3014 additions and 6 deletions
+37 -1
View File
@@ -42,6 +42,7 @@ from services import (
compose_service,
container_service,
device_service,
exec_service,
file_service,
image_service,
network_service,
@@ -63,7 +64,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.26.0"
AGENT_VERSION = "0.27.0"
# --------------------------------------------------------------------------- #
@@ -693,6 +694,41 @@ async def ws_deploy(websocket: WebSocket, stack_id: str, token: str | None = Que
compose_service.clear_busy(stack_id)
@app.websocket("/agent/ws/exec/{container_id}")
async def ws_exec(
websocket: WebSocket,
container_id: str,
token: str | None = Query(default=None),
cmd: str | None = Query(default=None),
):
"""Interactive shell into a compose-managed container (token via query)."""
await websocket.accept()
expected = settings.AGENT_TOKEN
if not expected or token != expected:
await websocket.close(code=4401)
return
shell = cmd or exec_service.DEFAULT_SHELL
try:
exec_id = exec_service.create_exec(container_id, [shell])
holder, raw = exec_service.start_exec(exec_id)
except Exception as exc: # noqa: BLE001
try:
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
except Exception: # noqa: BLE001
pass
await websocket.close()
return
try:
await exec_service.pump_exec(websocket, exec_id, holder, raw)
except WebSocketDisconnect:
pass
finally:
try:
await websocket.close()
except Exception: # noqa: BLE001
pass
@app.get("/agent/health")
def health() -> dict:
return {"status": "ok"}
+1 -1
View File
@@ -56,7 +56,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.26.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.27.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
+149 -1
View File
@@ -17,7 +17,7 @@ from auth import decode_token
from database import engine
from models.agent import Agent
from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START
from services import audit_service, compose_service, notify_service
from services import audit_service, compose_service, exec_service, notify_service
logger = logging.getLogger("stackpilot.ws")
@@ -37,6 +37,24 @@ async def _authorize(websocket: WebSocket, token: str | None) -> bool:
return True
async def _authorize_admin(websocket: WebSocket, token: str | None) -> bool:
"""Like _authorize but also requires the admin role (exec is root-equivalent).
Closes 4401 on a missing/invalid token, 4403 on a valid non-admin token."""
if not token:
await websocket.close(code=4401)
return False
try:
payload = decode_token(token, "access")
except (JWTError, Exception): # noqa: BLE001
await websocket.close(code=4401)
return False
if payload.get("role") != "admin":
await websocket.close(code=4403)
return False
return True
async def _stream_logs(websocket: WebSocket, stack_id: str, service: str | None):
"""Stream `docker compose logs -f` output to the client."""
args = ["logs", "--no-color", "--tail", "200", "--timestamps", "-f"]
@@ -348,3 +366,133 @@ async def ws_events(
finally:
stop.set()
task.cancel()
@router.websocket("/ws/exec/{container_id}")
async def ws_exec(
websocket: WebSocket,
container_id: str,
token: str | None = Query(default=None),
cmd: str | None = Query(default=None),
):
"""Interactive shell into a compose-managed container (admin only)."""
await websocket.accept()
if not await _authorize_admin(websocket, token):
return
username = decode_token(token, "access").get("sub", "unknown") if token else "unknown"
shell = cmd or exec_service.DEFAULT_SHELL
try:
exec_id = exec_service.create_exec(container_id, [shell])
holder, raw = exec_service.start_exec(exec_id)
except Exception as exc: # noqa: BLE001
with contextlib.suppress(Exception):
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
with contextlib.suppress(Exception):
await websocket.close()
return
with contextlib.suppress(Exception):
with Session(engine) as session:
audit_service.record(
session, user=username, action="container.exec",
target=container_id[:12], detail=shell, ip="ws",
)
try:
await exec_service.pump_exec(websocket, exec_id, holder, raw)
except WebSocketDisconnect:
pass
finally:
with contextlib.suppress(Exception):
await websocket.close()
@router.websocket("/ws/agent-exec/{agent_id}/{container_id}")
async def ws_agent_exec(
websocket: WebSocket,
agent_id: int,
container_id: str,
token: str | None = Query(default=None),
cmd: str | None = Query(default=None),
):
"""Proxy an interactive exec session to a remote agent (admin only).
Unlike the log/deploy proxies this forwards in BOTH directions so keystrokes
reach the container and its output streams back."""
await websocket.accept()
if not await _authorize_admin(websocket, token):
return
with Session(engine) as session:
agent = session.get(Agent, agent_id)
if not agent:
await websocket.send_text(json.dumps({"type": "error", "detail": "agent not found"}))
await websocket.close()
return
base = agent.url.rstrip("/")
ws_url = ("wss://" + base[8:] if base.startswith("https://")
else "ws://" + base[7:] if base.startswith("http://")
else "ws://" + base)
ws_url += f"/agent/ws/exec/{container_id}?token={urllib.parse.quote(agent.token, safe='')}"
if cmd:
ws_url += f"&cmd={urllib.parse.quote(cmd, safe='')}"
async def _err(detail: str) -> None:
with contextlib.suppress(Exception):
await websocket.send_text(json.dumps({"type": "error", "detail": detail}))
try:
upstream = await websockets.connect(ws_url, open_timeout=10, ping_interval=20)
except websockets.InvalidStatus as exc:
code = getattr(getattr(exc, "response", None), "status_code", None)
hint = " — the agent may be running an old version without terminal support; update it." if code == 404 else ""
logger.warning("Agent exec proxy: handshake to %s failed (%s)", agent.name, code)
await _err(f"Agent '{agent.name}' rejected the terminal (HTTP {code}){hint}")
with contextlib.suppress(Exception):
await websocket.close()
return
except Exception as exc: # noqa: BLE001
logger.warning("Agent exec proxy: cannot reach %s at %s: %s", agent.name, agent.url, exc)
await _err(f"Could not connect to agent '{agent.name}' at {agent.url}: {exc}")
with contextlib.suppress(Exception):
await websocket.close()
return
with contextlib.suppress(Exception):
with Session(engine) as session:
username = decode_token(token, "access").get("sub", "unknown") if token else "unknown"
audit_service.record(
session, user=username, action="agent.container.exec",
target=f"{agent.name}/{container_id[:12]}", ip="ws",
)
async def browser_to_agent() -> None:
try:
while True:
msg = await websocket.receive_text()
await upstream.send(msg)
except (WebSocketDisconnect, websockets.ConnectionClosed):
pass
async def agent_to_browser() -> None:
try:
async for message in upstream:
await websocket.send_text(
message if isinstance(message, str) else message.decode("utf-8", "replace")
)
except (WebSocketDisconnect, websockets.ConnectionClosed):
pass
b2a = asyncio.create_task(browser_to_agent())
a2b = asyncio.create_task(agent_to_browser())
done, pending = await asyncio.wait({b2a, a2b}, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
with contextlib.suppress(Exception):
await asyncio.gather(*pending, return_exceptions=True)
with contextlib.suppress(Exception):
await upstream.close()
with contextlib.suppress(Exception):
await websocket.close()
+136
View File
@@ -0,0 +1,136 @@
"""Interactive exec (web terminal) into compose-managed containers.
Reuses ``container_service._get_managed`` so a terminal can only be opened on a
container that belongs to a compose-managed stack — never an arbitrary host
container. The raw exec socket is bidirectional (stdin + a TTY-merged
stdout/stderr stream), suitable for piping straight to xterm.js.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import socket as _socket
from starlette.websockets import WebSocketDisconnect
from docker_client import DockerError, get_client, safe_call
from services.container_service import _get_managed
DEFAULT_SHELL = "/bin/sh"
def create_exec(container_id: str, cmd: list[str] | None = None, tty: bool = True) -> str:
"""Create an exec instance on a managed container and return its id."""
container = _get_managed(container_id)
client = get_client()
created = safe_call(
client.api.exec_create,
container.id,
cmd or [DEFAULT_SHELL],
stdin=True,
tty=tty,
stdout=True,
stderr=True,
)
return created["Id"]
def start_exec(exec_id: str, tty: bool = True):
"""Start the exec and return ``(holder, raw_socket)``.
docker-py 7.x returns a ``socket.SocketIO`` wrapper whose real, recv/sendall
-capable fd lives at ``._sock``; older versions hand back the socket
directly. We keep both: ``holder`` is what we close, ``raw`` is what we
recv/sendall on.
"""
client = get_client()
holder = safe_call(client.api.exec_start, exec_id, socket=True, tty=tty, demux=False)
raw = getattr(holder, "_sock", None) or holder
return holder, raw
def resize_exec(exec_id: str, height: int, width: int) -> None:
"""Resize the exec's TTY (rows x cols) so the shell wraps correctly."""
client = get_client()
safe_call(client.api.exec_resize, exec_id, height=height, width=width)
def exec_exit_code(exec_id: str):
"""Return the exec's ExitCode once it has finished (None while running)."""
client = get_client()
info = safe_call(client.api.exec_inspect, exec_id)
return info.get("ExitCode")
async def pump_exec(websocket, exec_id: str, holder, raw) -> None:
"""Bidirectionally pump an exec socket <-> a WebSocket.
Shared by the central app and the agent (both pass a Starlette WebSocket).
Browser -> container: JSON ``{"type":"data","data":...}`` keystrokes and
``{"type":"resize","rows","cols"}`` control frames (raw text is also
accepted as keystrokes). Container -> browser: ``{"type":"data","data":...}``
then a final ``{"type":"exit","code":...}``.
The blocking ``recv`` runs in the default executor; on teardown we shut the
socket down so that orphaned recv thread unblocks and exits.
"""
raw.setblocking(True)
loop = asyncio.get_event_loop()
closed = asyncio.Event()
async def to_browser() -> None:
try:
while True:
data = await loop.run_in_executor(None, raw.recv, 4096)
if not data:
break
await websocket.send_text(
json.dumps({"type": "data", "data": data.decode("utf-8", "replace")})
)
except Exception: # noqa: BLE001
pass
finally:
closed.set()
async def from_browser() -> None:
try:
while True:
msg = await websocket.receive_text()
obj = None
try:
obj = json.loads(msg)
except (json.JSONDecodeError, TypeError):
obj = None
if isinstance(obj, dict) and obj.get("type") == "resize":
with contextlib.suppress(Exception):
resize_exec(exec_id, int(obj.get("rows", 24)), int(obj.get("cols", 80)))
elif isinstance(obj, dict) and "data" in obj:
await loop.run_in_executor(None, raw.sendall, str(obj["data"]).encode())
else:
await loop.run_in_executor(None, raw.sendall, msg.encode())
except WebSocketDisconnect:
pass
except Exception: # noqa: BLE001
pass
finally:
closed.set()
out_task = asyncio.create_task(to_browser())
in_task = asyncio.create_task(from_browser())
await closed.wait()
with contextlib.suppress(Exception):
raw.shutdown(_socket.SHUT_RDWR)
with contextlib.suppress(Exception):
holder.close()
for task in (out_task, in_task):
task.cancel()
with contextlib.suppress(Exception):
await asyncio.gather(out_task, in_task, return_exceptions=True)
code = None
with contextlib.suppress(Exception):
code = exec_exit_code(exec_id)
with contextlib.suppress(Exception):
await websocket.send_text(json.dumps({"type": "exit", "code": code}))