fix(host-console): harden with security fixes, validation, and test coverage (#580)

Security:
- Enforce RBAC (system:console permission) on WebSocket upgrade
- Validate token_version for user sessions on WS connections
- Expand env var sanitization to cover additional secret patterns
  (PRIVATE, AUTH, PASSPHRASE, ENCRYPT, SIGNING) and connection
  strings (REDIS_URL, MONGO_URI, AMQP_URL, DSN)

Reliability:
- Add session tracking with max 5 concurrent console sessions
- Add WebSocket heartbeat (30s ping, 60s pong timeout) to detect
  and clean up dead connections and orphaned PTY processes
- Differentiate PTY spawn error messages (shell not found,
  permission denied, generic failure)
- Guard against duplicate cleanup when both WS close and PTY exit
  fire

Observability:
- Add structured logging with [HostConsole] prefix for session
  lifecycle (open, close, duration, user, pid)
- Add diagnostic logging behind developer_mode for terminal resize
  events and message parse errors

Frontend:
- Replace hardcoded hex colors with oklch CSS custom properties
  (--terminal-bg, --terminal-fg, --terminal-cursor, etc.)
- Apply design system material tokens (shadow-card-bevel, recessed
  well shadow, card border hierarchy, strokeWidth 1.5)

Tests:
- Add 20 tests covering env sanitization patterns (10 keyword
  categories + safe vars + case insensitivity), session tracking,
  and console-token RBAC (admin, viewer, deployer, API tokens)

Docs:
- Document admin role requirement and session limits
- Add troubleshooting section (session limits, shell not found,
  proxy timeouts, missing console tab)
- Update security section with expanded env var coverage
This commit is contained in:
Anso
2026-04-14 10:06:59 -04:00
committed by GitHub
parent f207e6000f
commit 718c1eb1ea
7 changed files with 436 additions and 47 deletions
+226
View File
@@ -0,0 +1,226 @@
/**
* Tests for the Host Console feature: environment sanitization, session limits,
* and console-token RBAC enforcement.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
beforeAll(async () => {
tmpDir = await setupTestDb();
// Mock LicenseService so Admiral-gated endpoints accept requests
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
({ app } = await import('../index'));
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
// ─── Environment Variable Sanitization ──────────────────────────────────────
describe('HostTerminalService.sanitizeEnv', () => {
let sanitizeEnv: (env: Record<string, string>) => Record<string, string>;
beforeAll(async () => {
const mod = await import('../services/HostTerminalService');
sanitizeEnv = mod.HostTerminalService.sanitizeEnv;
});
it('strips DATABASE_URL (explicit blocklist)', () => {
const result = sanitizeEnv({ DATABASE_URL: 'postgres://...', PATH: '/usr/bin' });
expect(result).not.toHaveProperty('DATABASE_URL');
expect(result).toHaveProperty('PATH', '/usr/bin');
});
it('strips REDIS_URL, MONGO_URI, AMQP_URL, DSN (explicit blocklist)', () => {
const result = sanitizeEnv({
REDIS_URL: 'redis://localhost',
MONGO_URI: 'mongodb://localhost',
AMQP_URL: 'amqp://localhost',
DSN: 'sentry://...',
HOME: '/home/user',
});
expect(result).not.toHaveProperty('REDIS_URL');
expect(result).not.toHaveProperty('MONGO_URI');
expect(result).not.toHaveProperty('AMQP_URL');
expect(result).not.toHaveProperty('DSN');
expect(result).toHaveProperty('HOME');
});
it('strips vars matching SECRET pattern', () => {
const result = sanitizeEnv({ JWT_SECRET: 'abc', APP_SECRET_KEY: 'xyz', LANG: 'en' });
expect(result).not.toHaveProperty('JWT_SECRET');
expect(result).not.toHaveProperty('APP_SECRET_KEY');
expect(result).toHaveProperty('LANG');
});
it('strips vars matching PASSWORD pattern', () => {
const result = sanitizeEnv({ DB_PASSWORD: 'pass', SMTP_PASSWORD: 'pass', USER: 'me' });
expect(result).not.toHaveProperty('DB_PASSWORD');
expect(result).not.toHaveProperty('SMTP_PASSWORD');
expect(result).toHaveProperty('USER');
});
it('strips vars matching TOKEN pattern', () => {
const result = sanitizeEnv({ API_TOKEN: '123', GITHUB_TOKEN: 'ghp_...', TERM: 'xterm' });
expect(result).not.toHaveProperty('API_TOKEN');
expect(result).not.toHaveProperty('GITHUB_TOKEN');
expect(result).toHaveProperty('TERM');
});
it('strips vars matching KEY pattern', () => {
const result = sanitizeEnv({ AWS_ACCESS_KEY_ID: 'AKIA...', ENCRYPTION_KEY: 'k', SHELL: '/bin/bash' });
expect(result).not.toHaveProperty('AWS_ACCESS_KEY_ID');
expect(result).not.toHaveProperty('ENCRYPTION_KEY');
expect(result).toHaveProperty('SHELL');
});
it('strips vars matching CREDENTIAL pattern', () => {
const result = sanitizeEnv({ GCP_CREDENTIAL: 'json...', PATH: '/usr/bin' });
expect(result).not.toHaveProperty('GCP_CREDENTIAL');
expect(result).toHaveProperty('PATH');
});
it('strips vars matching PRIVATE pattern', () => {
const result = sanitizeEnv({ SSH_PRIVATE_KEY: '-----BEGIN', PRIVATE_KEY_PEM: 'pem', HOSTNAME: 'box' });
expect(result).not.toHaveProperty('SSH_PRIVATE_KEY');
expect(result).not.toHaveProperty('PRIVATE_KEY_PEM');
expect(result).toHaveProperty('HOSTNAME');
});
it('strips vars matching AUTH pattern', () => {
const result = sanitizeEnv({ GITHUB_AUTH: 'token', OAUTH_CLIENT: 'id', COMPOSE_DIR: '/app' });
expect(result).not.toHaveProperty('GITHUB_AUTH');
expect(result).not.toHaveProperty('OAUTH_CLIENT');
expect(result).toHaveProperty('COMPOSE_DIR');
});
it('strips vars matching PASSPHRASE pattern', () => {
const result = sanitizeEnv({ GPG_PASSPHRASE: 'secret', PWD: '/home' });
expect(result).not.toHaveProperty('GPG_PASSPHRASE');
expect(result).toHaveProperty('PWD');
});
it('strips vars matching ENCRYPT pattern', () => {
const result = sanitizeEnv({ ENCRYPT_KEY: 'abc', ENCRYPTION_ALGO: 'aes', NODE_ENV: 'prod' });
expect(result).not.toHaveProperty('ENCRYPT_KEY');
expect(result).not.toHaveProperty('ENCRYPTION_ALGO');
expect(result).toHaveProperty('NODE_ENV');
});
it('strips vars matching SIGNING pattern', () => {
const result = sanitizeEnv({ SIGNING_KEY: 'key', JWT_SIGNING_SECRET: 's', LC_ALL: 'C' });
expect(result).not.toHaveProperty('SIGNING_KEY');
expect(result).not.toHaveProperty('JWT_SIGNING_SECRET');
expect(result).toHaveProperty('LC_ALL');
});
it('preserves safe environment variables', () => {
const safe = {
PATH: '/usr/bin',
HOME: '/home/user',
COMPOSE_DIR: '/app/compose',
NODE_ENV: 'production',
TERM: 'xterm-256color',
SHELL: '/bin/bash',
LANG: 'en_US.UTF-8',
};
const result = sanitizeEnv(safe);
expect(result).toEqual(safe);
});
it('pattern matching is case-insensitive', () => {
const result = sanitizeEnv({ my_secret: 'val', My_Password: 'val', PATH: '/usr/bin' });
expect(result).not.toHaveProperty('my_secret');
expect(result).not.toHaveProperty('My_Password');
expect(result).toHaveProperty('PATH');
});
});
// ─── Session Limit ──────────────────────────────────────────────────────────
describe('HostTerminalService session tracking', () => {
let HostTerminalService: typeof import('../services/HostTerminalService').HostTerminalService;
beforeAll(async () => {
const mod = await import('../services/HostTerminalService');
HostTerminalService = mod.HostTerminalService;
});
it('activeSessions map is accessible and starts empty', () => {
// Clear any leftover sessions
HostTerminalService.activeSessions.clear();
expect(HostTerminalService.activeSessions.size).toBe(0);
});
});
// ─── Console Token RBAC ─────────────────────────────────────────────────────
describe('POST /api/system/console-token', () => {
it('returns 401 without authentication', async () => {
const res = await request(app).post('/api/system/console-token');
expect(res.status).toBe(401);
});
it('returns 200 for admin user', async () => {
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
});
it('returns 403 for non-admin user (viewer role)', async () => {
// Create a viewer user in the DB
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const viewerHash = await bcrypt.hash('viewerpass', 1);
db.addUser({ username: 'viewer_test', password_hash: viewerHash, role: 'viewer' });
const token = jwt.sign({ username: 'viewer_test' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
});
it('returns 403 for deployer role', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const deployerHash = await bcrypt.hash('deployerpass', 1);
db.addUser({ username: 'deployer_test', password_hash: deployerHash, role: 'deployer' });
const token = jwt.sign({ username: 'deployer_test' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
});
it('returns 403 for API tokens', async () => {
// Create an API token via the admin endpoint first
const adminToken = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
const createRes = await request(app)
.post('/api/api-tokens')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: 'test-console-blocked', scope: 'full-admin' });
expect(createRes.status).toBe(201);
const apiTokenValue = createRes.body.token;
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${apiTokenValue}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
});
+40 -5
View File
@@ -2887,6 +2887,27 @@ server.on('upgrade', async (req, socket, head) => {
wsApiTokenScope = apiToken.scope;
}
// For user session tokens (no scope), resolve against DB for up-to-date role and
// token_version checks. This mirrors what authMiddleware does for HTTP requests.
// Scoped tokens (api_token, node_proxy, console_session) skip this: they are
// validated by their own logic above or by the gateway that issued them.
let wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
if (!decoded.scope && decoded.username) {
const dbUser = DatabaseService.getInstance().getUserByUsername(decoded.username);
if (!dbUser) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
console.log('[Auth] WS session rejected: token version mismatch for:', decoded.username);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wsResolvedUser = { username: dbUser.username, role: dbUser.role as UserRole, token_version: dbUser.token_version };
}
const url = req.url || '';
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
@@ -3018,10 +3039,22 @@ server.on('upgrade', async (req, socket, head) => {
socket.destroy();
return;
}
// RBAC gate: only users with 'system:console' permission may access the host console.
// Console_session tokens are pre-validated by the gateway's requireAdmin() middleware,
// so they skip this check. API tokens are already blocked by the scope gate above.
const isConsoleSession = decoded.scope === 'console_session';
if (!isConsoleSession) {
const userRole = wsResolvedUser?.role;
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes('system:console')) {
console.log('[HostConsole] Access denied: insufficient permissions', { username: wsResolvedUser?.username || decoded.username, role: userRole });
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
}
// Admiral license gate: host console requires Admiral (paid + team variant).
// For proxied connections (console_session tokens), trust the tier headers sent by the gateway;
// for direct connections, check the local LicenseService.
const isConsoleSession = decoded.scope === 'console_session';
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
const consoleVariantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
const ls = LicenseService.getInstance();
@@ -3036,13 +3069,15 @@ server.on('upgrade', async (req, socket, head) => {
socket.destroy();
return;
}
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
const stackParam = parsedUrl.searchParams.get('stack');
console.log('[HostConsole] WebSocket upgrade accepted', { username: consoleUsername, nodeId, stack: stackParam || '(root)' });
const hostConsoleWss = new WebSocketServer({ noServer: true });
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
hostConsoleWss.close();
let targetDirectory = '';
try {
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
const stackParam = parsedUrl.searchParams.get('stack');
if (stackParam) {
const resolved = path.resolve(baseDir, stackParam);
if (!resolved.startsWith(path.resolve(baseDir))) {
@@ -3058,11 +3093,11 @@ server.on('upgrade', async (req, socket, head) => {
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
}
try {
HostTerminalService.spawnTerminal(ws, targetDirectory);
HostTerminalService.spawnTerminal(ws, targetDirectory, consoleUsername);
} catch (error) {
console.error('Failed to spawn host terminal:', error);
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: (error as Error).message });
if (ws.readyState === WebSocket.OPEN) {
ws.send(`Error spawning terminal: ${(error as Error).message}\r\n`);
ws.send('Error: Failed to start terminal session.\r\n');
ws.close();
}
}
+109 -27
View File
@@ -2,40 +2,122 @@ import * as os from 'os';
import * as pty from 'node-pty';
import { WebSocket } from 'ws';
import { execSync } from 'child_process';
import { isDebugEnabled } from '../utils/debug';
function getUnixShell() {
let cachedShell: string | null = null;
function getUnixShell(): string {
if (cachedShell) return cachedShell;
try {
execSync('which bash', { stdio: 'ignore' });
return 'bash';
cachedShell = 'bash';
} catch (e) {
console.warn('[HostTerminalService] bash not found, falling back to sh:', (e as Error).message);
return 'sh';
cachedShell = 'sh';
}
return cachedShell;
}
export class HostTerminalService {
static spawnTerminal(ws: WebSocket, targetDirectory: string) {
const shell = os.platform() === 'win32' ? 'powershell.exe' : getUnixShell();
// Pattern-based filtering: block any env var whose name contains sensitive keywords.
// Broad matching is intentional; false positives (stripping a benign var like COLORTERM)
// are safer than false negatives (leaking a secret through printenv).
const SENSITIVE_PATTERNS = /SECRET|PASSWORD|TOKEN|KEY|CREDENTIAL|PRIVATE|AUTH|PASSPHRASE|ENCRYPT|SIGNING/i;
// Strip sensitive backend secrets from the PTY environment so they are not
// visible to the console user via `env` / `printenv`.
// Pattern-based filtering: block any env var containing sensitive keywords.
// Explicit fallback set catches vars that don't match patterns (e.g. DATABASE_URL).
const SENSITIVE_PATTERNS = /SECRET|PASSWORD|TOKEN|KEY|CREDENTIAL/i;
const SENSITIVE_KEYS = new Set(['DATABASE_URL']);
const safeEnv = Object.fromEntries(
Object.entries(process.env as Record<string, string>).filter(
// Explicit set catches well-known connection strings that may not match the pattern.
const SENSITIVE_KEYS = new Set(['DATABASE_URL', 'REDIS_URL', 'MONGO_URI', 'AMQP_URL', 'DSN']);
const MAX_CONSOLE_SESSIONS = 5;
const PING_INTERVAL_MS = 30_000;
const PONG_TIMEOUT_MS = 60_000;
// Cached sanitized environment; process.env does not change at runtime.
let cachedSafeEnv: Record<string, string> | null = null;
export class HostTerminalService {
static activeSessions = new Map<number, { username: string; startedAt: number }>();
/**
* Sanitize a set of environment variables by removing entries whose names
* match sensitive patterns or are in the explicit blocklist.
*/
static sanitizeEnv(env: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter(
([k]) => !SENSITIVE_PATTERNS.test(k) && !SENSITIVE_KEYS.has(k)
)
);
}
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 30,
cwd: targetDirectory,
env: safeEnv,
});
static spawnTerminal(ws: WebSocket, targetDirectory: string, username: string) {
// Enforce concurrent session limit
if (HostTerminalService.activeSessions.size >= MAX_CONSOLE_SESSIONS) {
console.warn('[HostConsole] Session rejected: max concurrent sessions reached', {
current: HostTerminalService.activeSessions.size,
max: MAX_CONSOLE_SESSIONS,
user: username,
});
ws.send('Error: Maximum console sessions reached. Close an existing session and try again.\r\n');
ws.close();
return;
}
const shell = os.platform() === 'win32' ? 'powershell.exe' : getUnixShell();
if (!cachedSafeEnv) {
cachedSafeEnv = HostTerminalService.sanitizeEnv(process.env as Record<string, string>);
}
const startedAt = Date.now();
let ptyProcess: pty.IPty;
try {
ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 30,
cwd: targetDirectory,
env: cachedSafeEnv,
});
} catch (e) {
const msg = (e as Error).message || '';
console.error('[HostConsole] Failed to spawn PTY', { user: username, directory: targetDirectory, error: msg });
if (/ENOENT|not found/i.test(msg)) {
ws.send('Error: Shell not found on this system. Ensure bash or sh is installed.\r\n');
} else if (/EACCES|permission/i.test(msg)) {
ws.send('Error: Permission denied when spawning shell process.\r\n');
} else {
ws.send('Error: Failed to start terminal session.\r\n');
}
ws.close();
return;
}
const pid = ptyProcess.pid;
HostTerminalService.activeSessions.set(pid, { username, startedAt });
console.log('[HostConsole] Session opened', { user: username, directory: targetDirectory, shell, pid });
// Guard against duplicate cleanup when both WS close and PTY exit fire
let cleaned = false;
const cleanup = (source: string, extra?: Record<string, unknown>) => {
if (cleaned) return;
cleaned = true;
clearInterval(pingInterval);
HostTerminalService.activeSessions.delete(pid);
const durationMs = Date.now() - startedAt;
console.log(`[HostConsole] Session closed (${source})`, { user: username, pid, durationMs, ...extra });
};
// Heartbeat: detect dead connections and clean up orphaned PTY processes
let lastPong = Date.now();
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
if (Date.now() - lastPong > PONG_TIMEOUT_MS) {
console.warn('[HostConsole] Heartbeat timeout, terminating session', { user: username, pid });
clearInterval(pingInterval);
ws.terminate();
ptyProcess.kill();
}
}
}, PING_INTERVAL_MS);
ws.on('pong', () => { lastPong = Date.now(); });
ptyProcess.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
@@ -43,27 +125,27 @@ export class HostTerminalService {
}
});
ws.on('message', (message: string) => {
ws.on('message', (raw: Buffer | ArrayBuffer | Buffer[]) => {
try {
const parsed = JSON.parse(message); // JSON-Up protocol
const parsed = JSON.parse(raw.toString()); // JSON-Up protocol
if (parsed.type === 'input') {
ptyProcess.write(parsed.payload);
} else if (parsed.type === 'resize') {
ptyProcess.resize(parsed.cols, parsed.rows);
if (isDebugEnabled()) console.debug('[HostConsole:diag] Terminal resized', { cols: parsed.cols, rows: parsed.rows, pid });
}
} catch (e) {
console.error('Failed to parse Host terminal message:', e);
console.error('[HostConsole] Failed to parse terminal message:', { pid, error: (e as Error).message });
}
});
ws.on('close', () => {
console.log('Host terminal WebSocket closed, cleaning up PTY process');
cleanup('WS');
ptyProcess.kill();
});
// Handle PTY process exit
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`Host terminal PTY process exited with code ${exitCode} and signal ${signal}`);
cleanup('PTY exit', { exitCode, signal });
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
+29 -3
View File
@@ -48,15 +48,21 @@ In a typical Docker deployment, the Sencho container runs Linux, so the console
## Availability
The Host Console is available to **admin** users on the **Admiral** tier. If your instance is on the Community or Skipper tier, the Console tab does not appear in the navigation bar at all. Attempting to access the console endpoint directly without the correct license is rejected.
The Host Console is available exclusively to users with the **admin** role on the **Admiral** tier. Other roles (node-admin, deployer, viewer, auditor) cannot access the console, even on Admiral-licensed instances. If your instance is on the Community or Skipper tier, the Console tab does not appear in the navigation bar at all. Attempting to access the console endpoint directly without the correct license or role is rejected.
## Session limits
Sencho enforces a maximum of **5 concurrent console sessions** per instance. If you hit this limit, close an existing session before opening a new one. Each session also includes an automatic heartbeat; if the browser tab is closed or the network drops, the server detects the dead connection and cleans up the session within about a minute.
## Security considerations
The Host Console has stricter access requirements than other features:
- **Browser sessions only.** API tokens used for multi-node communication cannot open a console session.
- **Admin role required.** Only users with the `admin` role can open a console session. Non-admin roles are blocked at both the UI and the API level.
- **Browser sessions only.** API tokens used for automation or multi-node communication cannot open a console session.
- **Admiral license required.** The license check is enforced at every layer, not just in the UI.
- **Sensitive environment variables are stripped.** Environment variables whose names suggest secrets (passwords, tokens, keys, credentials) and certain well-known database connection strings are automatically removed from the console environment. Running `env` or `printenv` inside the console will not reveal them.
- **Session invalidation.** Changing a user's password or role immediately invalidates any active session tokens. A user who is downgraded from admin loses console access instantly.
- **Sensitive environment variables are stripped.** Environment variables whose names suggest secrets (passwords, tokens, keys, credentials, auth-related variables, private keys, passphrases, encryption and signing secrets) and certain well-known connection strings are automatically removed from the console environment. Running `env` or `printenv` inside the console will not reveal them.
<Warning>
The Host Console provides unrestricted shell access to the machine running Sencho. Do not expose Sencho on a public network without HTTPS and strong authentication.
@@ -68,3 +74,23 @@ The Host Console has stricter access requirements than other features:
- Running `docker compose logs --follow` or `docker ps` directly
- Editing files with `nano` or `vim` for quick fixes
- Running maintenance scripts or one-off commands on the host
## Troubleshooting
<AccordionGroup>
<Accordion title="Maximum console sessions reached">
Sencho limits concurrent console sessions to 5 per instance. Close one or more existing console sessions (either through the UI or by closing the browser tab) and try again. Stale sessions from disconnected browsers are cleaned up automatically within about a minute.
</Accordion>
<Accordion title="Session ends immediately after connecting">
This typically means the shell could not be found on the host. In a Docker deployment, ensure `bash` or `sh` is available inside the container. You can check by running `docker exec <container> which bash` from the host. Also verify that your user has the `admin` role and your instance has an active Admiral license.
</Accordion>
<Accordion title="Connection drops after a short time">
If you are running Sencho behind a reverse proxy (e.g. Nginx, Caddy, Traefik), the proxy may be closing WebSocket connections after its default idle timeout. Increase the WebSocket timeout in your proxy configuration. For Nginx, set `proxy_read_timeout` and `proxy_send_timeout` to a higher value (e.g. `3600s`).
</Accordion>
<Accordion title="Console tab is not visible">
The Console tab only appears for users with the `admin` role on an Admiral-licensed instance. Verify both conditions are met. If you recently upgraded your license, you may need to refresh the page.
</Accordion>
</AccordionGroup>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

+21 -12
View File
@@ -11,6 +11,18 @@ interface HostConsoleProps {
onClose: () => void;
}
/** Build the xterm theme from CSS custom properties (resolved once per call). */
function getTerminalTheme() {
const s = getComputedStyle(document.documentElement);
return {
background: s.getPropertyValue('--terminal-bg').trim(),
foreground: s.getPropertyValue('--terminal-fg').trim(),
cursor: s.getPropertyValue('--terminal-cursor').trim(),
cursorAccent: s.getPropertyValue('--terminal-cursor-accent').trim(),
selectionBackground: s.getPropertyValue('--terminal-selection').trim(),
};
}
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
const { activeNode } = useNodes();
const terminalRef = useRef<HTMLDivElement>(null);
@@ -39,13 +51,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
let mounted = true;
const term = new Terminal({
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#000000',
selectionBackground: 'rgba(255, 255, 255, 0.3)',
},
theme: getTerminalTheme(),
fontFamily: "'Geist Mono', monospace",
fontSize: 14,
cursorBlink: true,
@@ -154,10 +160,10 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
}, [stackName, cleanup]);
return (
<div className="flex flex-col h-full w-full bg-background border rounded-lg overflow-hidden shadow-sm">
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0">
<div className="flex flex-col h-full w-full rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel overflow-hidden transition-colors hover:border-t-card-border-hover">
<div className="flex items-center justify-between px-4 py-2 border-b border-card-border bg-muted/40 shrink-0">
<div className="flex items-center gap-2 font-medium">
<TerminalIcon className="w-4 h-4 text-muted-foreground" />
<TerminalIcon className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span>Host Console</span>
{activeNode && (
<span className="text-muted-foreground font-normal text-sm">
@@ -176,11 +182,14 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
)}
</div>
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 gap-1.5 text-muted-foreground hover:text-foreground">
<X className="w-4 h-4" />
<X className="w-4 h-4" strokeWidth={1.5} />
Close Console
</Button>
</div>
<div className="flex-1 bg-[#1e1e1e] p-2 min-h-0 relative" style={{ overflow: 'hidden' }}>
<div
className="flex-1 p-2 min-h-0 relative shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]"
style={{ backgroundColor: 'var(--terminal-bg)', overflow: 'hidden' }}
>
<div ref={terminalRef} style={{ width: '100%', height: '100%' }} />
</div>
</div>
+11
View File
@@ -142,6 +142,13 @@
--tracking-normal: 0em;
--spacing: 0.25rem;
/* Terminal (dark well even in light theme) */
--terminal-bg: oklch(0.12 0 0);
--terminal-fg: oklch(0.85 0 0);
--terminal-cursor: oklch(1 0 0);
--terminal-cursor-accent: oklch(0 0 0);
--terminal-selection: oklch(1 0 0 / 0.3);
}
/* ─────────────────────────────────────────────────────────────
@@ -271,6 +278,10 @@
--shadow-lg: 0px 4px 6px -1px hsl(0 0% 0% / 0.15);
--shadow-xl: 0px 8px 10px -1px hsl(0 0% 0% / 0.20);
--shadow-2xl: 0px 12px 20px -2px hsl(0 0% 0% / 0.30);
/* Terminal (recessed well, matches page background)
--terminal-fg/cursor/selection: inherited from :root (both themes use a dark terminal) */
--terminal-bg: oklch(0.08 0 0);
}
/* ─────────────────────────────────────────────────────────────