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:
co-authored by
Claude Opus 4.8
parent
b44a5b9f86
commit
be3568274f
+37
-1
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user