mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
The dashboard console previously ran under a 'hosted' context that blocked most commands (auth add, config set model.*, mcp add --command, cron --script, ...) behind an allowlist + line-policy layer. With the full Hermes CLI now built into the dashboard, that policy layer is redundant gatekeeping: the console gets the same command surface everywhere. Removed: - ConsoleContext/contexts plumbing on ConsoleCommand + engine - EXPECTED_HOSTED_PATHS allowlist + _mark_hosted - _enforce_hosted_line_policy + HOSTED_CONFIG_* allow/block tables - _dashboard_console_context() and the context field on the ready frame - hosted-context tests; context badge in HermesConsoleModal Kept (mechanical, not policy): shell-syntax rejection, the interactive/server command blocks (gateway, dashboard, mcp serve, ...), mutating-command confirmations, output caps, and command timeouts.
533 lines
15 KiB
TypeScript
533 lines
15 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
import { Terminal as XtermTerminal } from "@xterm/xterm";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import { Terminal, X } from "lucide-react";
|
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
|
import { Button } from "@nous-research/ui/ui/components/button";
|
|
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
|
import { useProfileScope } from "@/contexts/useProfileScope";
|
|
import { api } from "@/lib/api";
|
|
import { cn, themedBody } from "@/lib/utils";
|
|
import { useTheme } from "@/themes";
|
|
|
|
type ConsoleFrame =
|
|
| {
|
|
type: "ready";
|
|
profile?: string;
|
|
prompt?: string;
|
|
}
|
|
| {
|
|
type: "output";
|
|
data?: string;
|
|
stream?: string;
|
|
}
|
|
| {
|
|
type: "error";
|
|
message?: string;
|
|
}
|
|
| {
|
|
type: "confirm_required";
|
|
command?: string;
|
|
message?: string;
|
|
prompt?: string;
|
|
}
|
|
| {
|
|
type: "complete";
|
|
status?: string;
|
|
prompt?: string;
|
|
}
|
|
| {
|
|
type: "clear";
|
|
}
|
|
| {
|
|
type: "pong";
|
|
};
|
|
|
|
type ConnectionState = "connecting" | "ready" | "running" | "closed" | "error";
|
|
|
|
interface HermesConsoleModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function buildTerminalTheme(background: string, foreground: string) {
|
|
return {
|
|
background,
|
|
foreground,
|
|
cursor: foreground,
|
|
cursorAccent: background,
|
|
selectionBackground: "rgba(255, 255, 255, 0.25)",
|
|
black: "#000000",
|
|
red: "#ff5f67",
|
|
green: "#5fffb0",
|
|
yellow: "#ffd166",
|
|
blue: "#7aa2ff",
|
|
magenta: "#d597ff",
|
|
cyan: "#58e6ff",
|
|
white: foreground,
|
|
brightBlack: "#666666",
|
|
brightRed: "#ff8b90",
|
|
brightGreen: "#8dffc8",
|
|
brightYellow: "#ffe08a",
|
|
brightBlue: "#9dbaff",
|
|
brightMagenta: "#e4b7ff",
|
|
brightCyan: "#8ef0ff",
|
|
brightWhite: "#ffffff",
|
|
};
|
|
}
|
|
|
|
function normalizeTerminalText(text: string): string {
|
|
return text.replace(/\r?\n/g, "\r\n");
|
|
}
|
|
|
|
function writeLine(term: XtermTerminal, text = ""): void {
|
|
term.write(`${normalizeTerminalText(text)}\r\n`);
|
|
}
|
|
|
|
function writeBlock(term: XtermTerminal, text: string): void {
|
|
const normalized = normalizeTerminalText(text);
|
|
term.write(normalized.endsWith("\r\n") ? normalized : `${normalized}\r\n`);
|
|
}
|
|
|
|
function isPrintable(data: string): boolean {
|
|
return data >= " " || data === "\t";
|
|
}
|
|
|
|
export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) {
|
|
const modalRef = useModalBehavior({ open, onClose });
|
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
const termRef = useRef<XtermTerminal | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const lineRef = useRef("");
|
|
const promptRef = useRef("hermes> ");
|
|
const inputPromptRef = useRef("hermes> ");
|
|
const historyRef = useRef<string[]>([]);
|
|
const historyIndexRef = useRef<number | null>(null);
|
|
const activeCommandRef = useRef(false);
|
|
const pendingCommandRef = useRef<string | null>(null);
|
|
const hasReadyFrameRef = useRef(false);
|
|
const [connectionState, setConnectionState] =
|
|
useState<ConnectionState>("connecting");
|
|
const [consoleProfile, setConsoleProfile] = useState("current");
|
|
const { profile } = useProfileScope();
|
|
const { theme } = useTheme();
|
|
|
|
const redrawInput = useCallback((line = lineRef.current) => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
lineRef.current = line;
|
|
term.write(`\r\x1b[2K${inputPromptRef.current}${line}`);
|
|
}, []);
|
|
|
|
const showPrompt = useCallback(() => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
lineRef.current = "";
|
|
historyIndexRef.current = null;
|
|
inputPromptRef.current = promptRef.current;
|
|
term.write(inputPromptRef.current);
|
|
}, []);
|
|
|
|
const sendFrame = useCallback((payload: Record<string, unknown>) => {
|
|
const ws = wsRef.current;
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
ws.send(JSON.stringify(payload));
|
|
return true;
|
|
}, []);
|
|
|
|
const cancelCommand = useCallback(() => {
|
|
pendingCommandRef.current = null;
|
|
activeCommandRef.current = false;
|
|
sendFrame({ type: "cancel" });
|
|
}, [sendFrame]);
|
|
|
|
const submitLine = useCallback(
|
|
(rawLine: string) => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
const line = rawLine.trim();
|
|
term.write("\r\n");
|
|
lineRef.current = "";
|
|
historyIndexRef.current = null;
|
|
|
|
const pending = pendingCommandRef.current;
|
|
if (pending) {
|
|
const answer = line.toLowerCase();
|
|
if (answer === "y" || answer === "yes") {
|
|
pendingCommandRef.current = null;
|
|
activeCommandRef.current = true;
|
|
setConnectionState("running");
|
|
sendFrame({ type: "confirm", command: pending });
|
|
return;
|
|
}
|
|
cancelCommand();
|
|
return;
|
|
}
|
|
|
|
if (!line) {
|
|
showPrompt();
|
|
return;
|
|
}
|
|
|
|
historyRef.current = [...historyRef.current, line].slice(-200);
|
|
activeCommandRef.current = true;
|
|
setConnectionState("running");
|
|
if (!sendFrame({ type: "input", line })) {
|
|
activeCommandRef.current = false;
|
|
writeLine(term, "\x1b[31mConsole is not connected.\x1b[0m");
|
|
showPrompt();
|
|
}
|
|
},
|
|
[cancelCommand, sendFrame, showPrompt],
|
|
);
|
|
|
|
const recallHistory = useCallback(
|
|
(direction: -1 | 1) => {
|
|
const history = historyRef.current;
|
|
if (!history.length) return;
|
|
const current = historyIndexRef.current;
|
|
if (current === null) {
|
|
if (direction > 0) return;
|
|
historyIndexRef.current = history.length - 1;
|
|
} else {
|
|
const next = current + direction;
|
|
if (next < 0) historyIndexRef.current = 0;
|
|
else if (next >= history.length) {
|
|
historyIndexRef.current = null;
|
|
redrawInput("");
|
|
return;
|
|
} else {
|
|
historyIndexRef.current = next;
|
|
}
|
|
}
|
|
const idx = historyIndexRef.current;
|
|
redrawInput(idx === null ? "" : history[idx] ?? "");
|
|
},
|
|
[redrawInput],
|
|
);
|
|
|
|
const handleInputData = useCallback(
|
|
(data: string) => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
|
|
if (data === "\x1b[A") {
|
|
recallHistory(-1);
|
|
return;
|
|
}
|
|
if (data === "\x1b[B") {
|
|
recallHistory(1);
|
|
return;
|
|
}
|
|
|
|
for (const ch of data) {
|
|
if (ch === "\u0003") {
|
|
term.write("^C\r\n");
|
|
if (activeCommandRef.current || pendingCommandRef.current) {
|
|
cancelCommand();
|
|
} else {
|
|
showPrompt();
|
|
}
|
|
continue;
|
|
}
|
|
if (ch === "\u000c") {
|
|
term.clear();
|
|
showPrompt();
|
|
continue;
|
|
}
|
|
if (activeCommandRef.current) {
|
|
term.write("\x07");
|
|
continue;
|
|
}
|
|
if (ch === "\r" || ch === "\n") {
|
|
submitLine(lineRef.current);
|
|
continue;
|
|
}
|
|
if (ch === "\u007f" || ch === "\b") {
|
|
if (lineRef.current.length > 0) {
|
|
lineRef.current = lineRef.current.slice(0, -1);
|
|
term.write("\b \b");
|
|
}
|
|
continue;
|
|
}
|
|
if (ch === "\x1b") {
|
|
continue;
|
|
}
|
|
if (isPrintable(ch)) {
|
|
lineRef.current += ch;
|
|
term.write(ch);
|
|
}
|
|
}
|
|
},
|
|
[cancelCommand, recallHistory, showPrompt, submitLine],
|
|
);
|
|
|
|
const handleFrame = useCallback(
|
|
(frame: ConsoleFrame) => {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
|
|
if (frame.type === "ready") {
|
|
const nextPrompt = frame.prompt || "hermes> ";
|
|
promptRef.current = nextPrompt;
|
|
inputPromptRef.current = nextPrompt;
|
|
hasReadyFrameRef.current = true;
|
|
setConsoleProfile(frame.profile || "current");
|
|
activeCommandRef.current = false;
|
|
setConnectionState("ready");
|
|
term.clear();
|
|
showPrompt();
|
|
return;
|
|
}
|
|
|
|
if (frame.type === "output") {
|
|
if (frame.data) writeBlock(term, frame.data);
|
|
return;
|
|
}
|
|
|
|
if (frame.type === "error") {
|
|
writeLine(term, `\x1b[31m${frame.message || "Command failed."}\x1b[0m`);
|
|
return;
|
|
}
|
|
|
|
if (frame.type === "confirm_required") {
|
|
pendingCommandRef.current = frame.command || "";
|
|
activeCommandRef.current = false;
|
|
setConnectionState("ready");
|
|
if (frame.message) {
|
|
writeLine(term, `\x1b[33m${frame.message}\x1b[0m`);
|
|
}
|
|
inputPromptRef.current = "Confirm? [y/N] ";
|
|
lineRef.current = "";
|
|
term.write(inputPromptRef.current);
|
|
return;
|
|
}
|
|
|
|
if (frame.type === "complete") {
|
|
activeCommandRef.current = false;
|
|
if (frame.prompt) promptRef.current = frame.prompt;
|
|
if (frame.status === "confirm_required") return;
|
|
if (frame.status === "exit") {
|
|
setConnectionState("closed");
|
|
wsRef.current?.close();
|
|
return;
|
|
}
|
|
if (frame.status === "timeout") {
|
|
writeLine(term, "\x1b[31mCommand timed out.\x1b[0m");
|
|
}
|
|
if (frame.status === "cancelled") {
|
|
writeLine(term, "\x1b[33mCancelled.\x1b[0m");
|
|
}
|
|
pendingCommandRef.current = null;
|
|
setConnectionState("ready");
|
|
showPrompt();
|
|
return;
|
|
}
|
|
|
|
if (frame.type === "clear") {
|
|
term.clear();
|
|
showPrompt();
|
|
}
|
|
},
|
|
[showPrompt],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const host = hostRef.current;
|
|
if (!host) return;
|
|
|
|
let cancelled = false;
|
|
let resizeFrame = 0;
|
|
const term = new XtermTerminal({
|
|
allowProposedApi: true,
|
|
cursorBlink: true,
|
|
fontFamily:
|
|
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
|
|
fontSize: 13,
|
|
lineHeight: 1.25,
|
|
letterSpacing: 0,
|
|
macOptionIsMeta: true,
|
|
scrollback: 3000,
|
|
theme: buildTerminalTheme(
|
|
theme.terminalBackground ?? "#000000",
|
|
theme.terminalForeground ?? "#f0e6d2",
|
|
),
|
|
});
|
|
termRef.current = term;
|
|
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
const unicode11 = new Unicode11Addon();
|
|
term.loadAddon(unicode11);
|
|
term.unicode.activeVersion = "11";
|
|
term.loadAddon(new WebLinksAddon());
|
|
term.open(host);
|
|
term.focus();
|
|
|
|
const fitTerminal = () => {
|
|
if (!host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
|
return;
|
|
}
|
|
try {
|
|
fit.fit();
|
|
} catch {
|
|
/* fit can fail while the modal is closing */
|
|
}
|
|
};
|
|
const scheduleFit = () => {
|
|
if (resizeFrame) return;
|
|
resizeFrame = requestAnimationFrame(() => {
|
|
resizeFrame = 0;
|
|
fitTerminal();
|
|
});
|
|
};
|
|
const ro = new ResizeObserver(scheduleFit);
|
|
ro.observe(host);
|
|
scheduleFit();
|
|
|
|
const dataDisposable = term.onData(handleInputData);
|
|
setConnectionState("connecting");
|
|
setConsoleProfile(profile || "current");
|
|
hasReadyFrameRef.current = false;
|
|
writeLine(term, "\x1b[2mConnecting to Hermes Console...\x1b[0m");
|
|
|
|
void (async () => {
|
|
try {
|
|
const params = profile ? { profile } : undefined;
|
|
const url = await api.buildWsUrl("/api/console", params);
|
|
if (cancelled) return;
|
|
const ws = new WebSocket(url);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
setConnectionState("connecting");
|
|
};
|
|
|
|
ws.onmessage = (ev) => {
|
|
try {
|
|
const frame = JSON.parse(String(ev.data)) as ConsoleFrame;
|
|
handleFrame(frame);
|
|
} catch {
|
|
writeLine(term, "\x1b[31mMalformed console frame.\x1b[0m");
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setConnectionState("error");
|
|
writeLine(term, "\x1b[31mConsole websocket error.\x1b[0m");
|
|
};
|
|
|
|
ws.onclose = (ev) => {
|
|
wsRef.current = null;
|
|
activeCommandRef.current = false;
|
|
pendingCommandRef.current = null;
|
|
if (cancelled) return;
|
|
setConnectionState(ev.code === 1000 ? "closed" : "error");
|
|
const reason = ev.reason ? ` ${ev.reason}` : "";
|
|
const message =
|
|
ev.code === 1006 && !hasReadyFrameRef.current
|
|
? "Console connection failed before the server handshake. Check that this dashboard is connected to a backend with /api/console."
|
|
: `Console closed (${ev.code}).${reason}`;
|
|
writeLine(term, `\x1b[31m${message}\x1b[0m`);
|
|
};
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
setConnectionState("error");
|
|
writeLine(term, `\x1b[31mConsole unavailable: ${err}\x1b[0m`);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
dataDisposable.dispose();
|
|
ro.disconnect();
|
|
if (resizeFrame) cancelAnimationFrame(resizeFrame);
|
|
wsRef.current?.close();
|
|
wsRef.current = null;
|
|
term.dispose();
|
|
termRef.current = null;
|
|
lineRef.current = "";
|
|
pendingCommandRef.current = null;
|
|
activeCommandRef.current = false;
|
|
hasReadyFrameRef.current = false;
|
|
};
|
|
}, [handleFrame, handleInputData, open, profile, theme]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
term.options.theme = buildTerminalTheme(
|
|
theme.terminalBackground ?? "#000000",
|
|
theme.terminalForeground ?? "#f0e6d2",
|
|
);
|
|
}, [open, theme]);
|
|
|
|
if (!open) return null;
|
|
|
|
const statusTone =
|
|
connectionState === "ready"
|
|
? "success"
|
|
: connectionState === "running"
|
|
? "warning"
|
|
: connectionState === "connecting"
|
|
? "secondary"
|
|
: "destructive";
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={modalRef}
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-3 sm:p-4"
|
|
onClick={(event) => event.target === event.currentTarget && onClose()}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="hermes-console-title"
|
|
>
|
|
<div
|
|
className={cn(
|
|
themedBody,
|
|
"relative flex h-[min(82dvh,760px)] w-full max-w-5xl flex-col border border-border bg-card shadow-2xl",
|
|
)}
|
|
>
|
|
<header className="flex min-h-14 items-center gap-3 border-b border-border px-4 py-3">
|
|
<div className="flex h-9 w-9 items-center justify-center border border-border bg-background/60 text-primary">
|
|
<Terminal className="h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<h2
|
|
id="hermes-console-title"
|
|
className="font-mondwest text-display text-base tracking-wider"
|
|
>
|
|
Hermes Console
|
|
</h2>
|
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
<Badge tone={statusTone}>{connectionState}</Badge>
|
|
<span className="font-mono">{consoleProfile}</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
onClick={onClose}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
aria-label="Close console"
|
|
>
|
|
<X />
|
|
</Button>
|
|
</header>
|
|
<div className="min-h-0 flex-1 bg-black">
|
|
<div
|
|
ref={hostRef}
|
|
className="h-full min-h-0 w-full overflow-hidden p-2 [&_.xterm]:h-full [&_.xterm-viewport]:!bg-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|