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
@@ -1,9 +1,10 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Play, Square, RotateCw, ChevronDown, ChevronRight } from "lucide-react";
import { Play, Square, RotateCw, ChevronDown, ChevronRight, TerminalSquare } from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
import { ContainerTerminal } from "@/components/stacks/ContainerTerminal";
import { containersApi, type ContainerAction } from "@/api/containers";
import { apiErrorMessage } from "@/api/client";
import type { ContainerInfo } from "@/types";
@@ -23,6 +24,7 @@ export function ContainerCard({
}) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [termOpen, setTermOpen] = useState(false);
const running = container.state === "running";
const detail = useQuery({
@@ -79,6 +81,15 @@ export function ContainerCard({
<Button variant="outline" className="px-2 py-1" onClick={() => act("restart")} disabled={busy || !running}>
<RotateCw className="h-3.5 w-3.5 text-sky-500" />
</Button>
<Button
variant="outline"
className="px-2 py-1"
onClick={() => setTermOpen(true)}
disabled={!running}
title="Open terminal"
>
<TerminalSquare className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
@@ -137,6 +148,15 @@ export function ContainerCard({
)}
</div>
)}
{termOpen && (
<ContainerTerminal
containerId={container.id}
service={container.service}
agentId={agentId}
onClose={() => setTermOpen(false)}
/>
)}
</Card>
);
}
@@ -0,0 +1,168 @@
import { useEffect, useRef, useState } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { X } from "lucide-react";
import { Button } from "@/components/ui";
import { useAuthStore } from "@/store/auth";
type Status = "connecting" | "open" | "closed" | "error";
const SHELLS = ["/bin/sh", "/bin/bash", "/bin/ash"];
/**
* Interactive terminal modal: opens an exec session into a compose-managed
* container over `/ws/exec/{id}` (or `/ws/agent-exec/{aid}/{id}` for a remote
* agent) and wires it to an xterm.js terminal. Admin-only on the backend; a
* 4403 close surfaces as an "admin only" error.
*/
export function ContainerTerminal({
containerId,
service,
agentId,
onClose,
}: {
containerId: string;
service: string;
agentId?: number;
onClose: () => void;
}) {
const token = useAuthStore((s) => s.accessToken);
const [shell, setShell] = useState(SHELLS[0]);
const [status, setStatus] = useState<Status>("connecting");
const [detail, setDetail] = useState<string | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!token || !boxRef.current) return;
setStatus("connecting");
setDetail(null);
const term = new Terminal({
fontSize: 13,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
cursorBlink: true,
theme: { background: "#020617", foreground: "#e2e8f0", cursor: "#38bdf8" },
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(boxRef.current);
fit.fit();
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const path =
agentId != null
? `/ws/agent-exec/${agentId}/${containerId}`
: `/ws/exec/${containerId}`;
const url =
`${proto}://${window.location.host}${path}` +
`?token=${encodeURIComponent(token)}&cmd=${encodeURIComponent(shell)}`;
const ws = new WebSocket(url);
const sendResize = () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", rows: term.rows, cols: term.cols }));
}
};
ws.onopen = () => {
setStatus("open");
term.focus();
sendResize();
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "data") term.write(msg.data as string);
else if (msg.type === "exit") {
term.write(`\r\n\x1b[90m[process exited${msg.code != null ? ` with code ${msg.code}` : ""}]\x1b[0m\r\n`);
setStatus("closed");
} else if (msg.type === "error") {
term.write(`\r\n\x1b[31m${msg.detail || "error"}\x1b[0m\r\n`);
setStatus("error");
setDetail(msg.detail || "error");
}
} catch {
/* non-JSON: write raw */
term.write(typeof ev.data === "string" ? ev.data : "");
}
};
ws.onclose = (ev) => {
if (ev.code === 4403) {
setStatus("error");
setDetail("Terminal access is admin-only.");
term.write("\r\n\x1b[31mTerminal access is admin-only.\x1b[0m\r\n");
} else if (ev.code === 4401) {
setStatus("error");
setDetail("Authentication failed.");
} else {
setStatus((s) => (s === "open" ? "closed" : s));
}
};
const onData = term.onData((d) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "data", data: d }));
});
const ro = new ResizeObserver(() => {
try {
fit.fit();
sendResize();
} catch {
/* ignore */
}
});
ro.observe(boxRef.current);
return () => {
ro.disconnect();
onData.dispose();
ws.close();
term.dispose();
};
}, [containerId, agentId, shell, token]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">Terminal {service}</h2>
<span
className={
status === "open"
? "text-xs text-green-500"
: status === "error"
? "text-xs text-red-500"
: "text-xs text-slate-500"
}
>
{status === "connecting" && "connecting…"}
{status === "open" && "connected"}
{status === "closed" && "closed"}
{status === "error" && (detail || "error")}
</span>
</div>
<div className="flex items-center gap-2">
<select
value={shell}
onChange={(e) => setShell(e.target.value)}
className="rounded-md border border-slate-300 bg-transparent px-2 py-1 text-sm dark:border-slate-600"
title="Shell — switching reconnects"
>
{SHELLS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<Button variant="outline" className="px-2 py-1" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div ref={boxRef} className="min-h-0 flex-1 overflow-hidden bg-[#020617] p-2" />
</div>
</div>
);
}