feat: graduate Host Console to Community admins (#1669)

* feat: graduate Host Console to Community admins

Make Host Console available to Community and Admiral admins (system:console), add host-console-community for mixed fleets, and keep opaque API tokens off the host shell.

* docs: document Host Console deep links

Cover root and stack-scoped Console URLs, correct the phone treatment note, and pin parse/build round-trips in senchoRoute tests.

* fix: bind Host Console socket to the resolved node

Treat unresolved activeNode as loading, target the WebSocket with an explicit nodeId, and wait for stack deep-link hydration so the shell cannot open on the wrong node or compose root. Add regression coverage for node/stack retargeting and fail-closed directory resolution.

* fix: harden Host Console node binding, audit acting_as, and console_session tokens

Reject unknown or malformed nodeIds before spawning a PTY. Record hub operators in audit_log.acting_as for remote console_session bridges. Path-scope and one-time-consume console_session JWTs so Host Console mints cannot open container exec or be replayed.

* test: expect acting_as in audit CSV export header

Align the CSV export assertion with the P0-2B acting_as column added to audit log exports.
This commit is contained in:
Anso
2026-07-23 12:59:53 -04:00
committed by GitHub
parent ed5ca9c4f6
commit dd54a2e483
43 changed files with 1230 additions and 199 deletions
+14 -9
View File
@@ -8,6 +8,8 @@ import { useNodes } from '@/context/NodeContext';
import { copyToClipboard } from '@/lib/clipboard';
interface HostConsoleProps {
/** Resolved active node id; WebSocket must target this id, not localStorage. */
nodeId: number;
stackName?: string | null;
onClose: () => void;
}
@@ -27,7 +29,7 @@ function formatUptime(ms: number): string {
type ConnState = 'reconnecting' | 'connected' | 'disconnected';
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
export default function HostConsole({ nodeId, stackName, onClose }: HostConsoleProps) {
const { activeNode } = useNodes();
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
@@ -93,12 +95,12 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
});
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : '';
const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : '';
const queryString = [nodeParam, stackParam].filter(Boolean).join('&');
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`;
const ws = new WebSocket(wsUrl);
const qs = stackName
? `nodeId=${nodeId}&stack=${encodeURIComponent(stackName)}`
: `nodeId=${nodeId}`;
const ws = new WebSocket(
`${wsProtocol}//${window.location.host}/api/system/host-console?${qs}`,
);
wsRef.current = ws;
ws.onopen = () => {
@@ -194,7 +196,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
fitAddonRef.current = null;
serializeRef.current = null;
};
}, [stackName, reconnectNonce]);
}, [nodeId, stackName, reconnectNonce]);
const handleCopy = useCallback(() => {
const term = xtermRef.current;
@@ -236,7 +238,10 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
const stateWord = connState === 'disconnected'
? 'Disconnected'
: connState === 'reconnecting' ? 'Reconnecting' : 'Connected';
const nodeLabel = activeNode ? (activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase()) : 'LOCAL';
let nodeLabel = `NODE ${nodeId}`;
if (activeNode?.id === nodeId) {
nodeLabel = activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase();
}
const kicker = `HOST CONSOLE · ${nodeLabel}`;
const uptime = mountedAt != null ? formatUptime(tick - mountedAt) : '—';