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
@@ -76,4 +76,8 @@ describe('WebSocket API-token scope enforcement', () => {
it('does not scope-block a full-admin token from a generic socket', async () => {
expect(await upgradeStatus(createToken('full-admin'), '/ws')).not.toBe(403);
});
it('blocks a full-admin token from the host console (403)', async () => {
expect(await upgradeStatus(createToken('full-admin'), '/api/system/host-console')).toBe(403);
});
});
+1 -1
View File
@@ -715,7 +715,7 @@ describe('GET /api/audit-log/export', () => {
const csvText = res.text;
const lines = csvText.split('\n');
expect(lines[0]).toBe('id,timestamp,username,method,path,status_code,node_id,ip_address,summary');
expect(lines[0]).toBe('id,timestamp,username,acting_as,method,path,status_code,node_id,ip_address,summary');
expect(lines.length).toBeGreaterThan(1);
});
+2 -1
View File
@@ -115,7 +115,8 @@ describe('POST /api/system/console-token', () => {
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}`);
.set('Authorization', `Bearer ${token}`)
.send({ path: 'host-console' });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
});
@@ -70,15 +70,18 @@ describe('fetchRemoteMeta Authorization header', () => {
describe('applyPilotModeCapabilityFilter', () => {
afterEach(() => {
enableCapability('host-console');
enableCapability('host-console-community');
});
it('removes host-console from active capabilities', () => {
it('removes host-console and host-console-community from active capabilities', () => {
expect(CAPABILITIES).toContain('host-console');
expect(CAPABILITIES).toContain('host-console-community');
applyPilotModeCapabilityFilter();
const active = getActiveCapabilities();
expect(active).not.toContain('host-console');
expect(active).not.toContain('host-console-community');
expect(active).toContain('stacks');
});
@@ -97,6 +100,7 @@ describe('applyPilotModeCapabilityFilter', () => {
const active = getActiveCapabilities();
expect(active).not.toContain('host-console');
expect(active.length).toBe(CAPABILITIES.length - 1);
expect(active).not.toContain('host-console-community');
expect(active.length).toBe(CAPABILITIES.length - 2);
});
});
+132 -4
View File
@@ -21,8 +21,8 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
beforeAll(async () => {
vi.restoreAllMocks();
tmpDir = await setupTestDb();
// Host console requires the paid tier; mock the license so the tier gate
// passes for the admin/accepted cases. Individual tests override as needed.
// Host console is available on every tier for admins; mock paid so other
// suites that share LicenseService state stay stable.
const { LicenseService } = await import('../services/LicenseService');
getTierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const mod = await import('../index');
@@ -78,10 +78,28 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
expect(await expectRejected(ws)).toBe(403);
});
it('rejects an admin on the Community tier (403)', async () => {
it('accepts a Community-tier admin', async () => {
getTierSpy.mockReturnValueOnce('community');
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
expect(await expectRejected(ws)).toBe(403);
const opened = await new Promise<boolean>((resolve) => {
ws.on('open', () => { ws.close(); resolve(true); });
ws.on('error', () => resolve(false));
ws.on('unexpected-response', () => resolve(false));
});
expect(opened).toBe(true);
});
it('accepts a console_session Bearer on Community (remote bridge mint path)', async () => {
getTierSpy.mockReturnValueOnce('community');
const { mintConsoleSession } = await import('../helpers/consoleSession');
const token = mintConsoleSession({ path: 'host-console', actingAs: TEST_USERNAME });
const ws = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
const opened = await new Promise<boolean>((resolve) => {
ws.on('open', () => { ws.close(); resolve(true); });
ws.on('error', () => resolve(false));
ws.on('unexpected-response', () => resolve(false));
});
expect(opened).toBe(true);
});
it('accepts an admin on Admiral and records an open audit row', async () => {
@@ -127,6 +145,116 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
expect(firstMessage).toContain('Invalid stack path');
ws.close();
});
it('rejects an unknown nodeId without spawning a PTY (404)', async () => {
const { HostTerminalService } = await import('../services/HostTerminalService');
const spawnSpy = vi.spyOn(HostTerminalService, 'spawnTerminal');
try {
const ws = new WebSocket(wsUrl('?nodeId=99999999'), {
headers: { Cookie: `sencho_token=${adminToken()}` },
});
expect(await expectRejected(ws)).toBe(404);
expect(spawnSpy).not.toHaveBeenCalled();
} finally {
spawnSpy.mockRestore();
}
});
it('rejects a malformed nodeId that parseInt would coerce (404)', async () => {
const ws = new WebSocket(wsUrl('?nodeId=1abc'), {
headers: { Cookie: `sencho_token=${adminToken()}` },
});
expect(await expectRejected(ws)).toBe(404);
});
it('rejects zero and negative nodeId values (404)', async () => {
for (const id of ['0', '-1']) {
const ws = new WebSocket(wsUrl(`?nodeId=${id}`), {
headers: { Cookie: `sencho_token=${adminToken()}` },
});
expect(await expectRejected(ws)).toBe(404);
}
});
it('rejects a replayed console_session token on second Host Console upgrade', async () => {
const { mintConsoleSession } = await import('../helpers/consoleSession');
const token = mintConsoleSession({ path: 'host-console', actingAs: 'qaadmin' });
const first = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
const firstOpened = await new Promise<boolean>((resolve) => {
first.on('open', () => { first.close(); resolve(true); });
first.on('error', () => resolve(false));
first.on('unexpected-response', () => resolve(false));
});
expect(firstOpened).toBe(true);
const second = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
expect(await expectRejected(second)).toBe(401);
});
it('rejects a host-console console_session on the container-exec /ws path', async () => {
const { mintConsoleSession } = await import('../helpers/consoleSession');
const token = mintConsoleSession({ path: 'host-console' });
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('Server not listening');
const ws = new WebSocket(`ws://127.0.0.1:${addr.port}/ws`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(await expectRejected(ws)).toBe(403);
});
it('records acting_as on console_session open audit rows', async () => {
const { mintConsoleSession } = await import('../helpers/consoleSession');
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const insertSpy = vi.spyOn(db, 'insertAuditLog');
const token = mintConsoleSession({ path: 'host-console', actingAs: 'qaadmin' });
try {
const ws = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
const opened = await new Promise<boolean>((resolve) => {
ws.on('open', () => { ws.close(); resolve(true); });
ws.on('error', () => resolve(false));
ws.on('unexpected-response', () => resolve(false));
});
expect(opened).toBe(true);
await waitFor(() => insertSpy.mock.calls.some((c) => {
const e = c[0] as { summary?: string };
return typeof e.summary === 'string' && e.summary.includes('Opened host console');
}));
const openEntry = insertSpy.mock.calls
.map((c) => c[0] as { username: string; acting_as?: string | null; summary: string })
.find((e) => e.summary.includes('Opened host console'));
expect(openEntry?.username).toBe('console_session');
expect(openEntry?.acting_as).toBe('qaadmin');
} finally {
insertSpy.mockRestore();
}
});
it('closes without spawning a PTY when directory resolution throws (no default-node fallback)', async () => {
const { FileSystemService } = await import('../services/FileSystemService');
const { HostTerminalService } = await import('../services/HostTerminalService');
const spawnSpy = vi.spyOn(HostTerminalService, 'spawnTerminal');
const getInstanceSpy = vi.spyOn(FileSystemService, 'getInstance').mockImplementation(() => {
throw new Error('compose dir unavailable');
});
try {
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
const firstMessage = await new Promise<string>((resolve) => {
ws.on('message', (data) => resolve(data.toString()));
ws.on('error', () => resolve(''));
ws.on('unexpected-response', () => resolve(''));
});
expect(firstMessage).toMatch(/Failed to resolve console directory/i);
expect(spawnSpy).not.toHaveBeenCalled();
// getInstance must not be retried against a fallback/default node id.
expect(getInstanceSpy).toHaveBeenCalledTimes(1);
ws.close();
} finally {
spawnSpy.mockRestore();
getInstanceSpy.mockRestore();
}
});
});
/** Poll a predicate up to ~1s; resolve true as soon as it passes. */
+34 -1
View File
@@ -375,9 +375,42 @@ describe('POST /api/system/console-token', () => {
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}`);
.set('Authorization', `Bearer ${token}`)
.send({ path: 'host-console' });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
const decoded = jwt.verify(res.body.token, TEST_JWT_SECRET) as { scope?: string; path?: string; jti?: string };
expect(decoded.scope).toBe('console_session');
expect(decoded.path).toBe('host-console');
expect(typeof decoded.jti).toBe('string');
});
it('returns 400 when path is missing', 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}`)
.send({});
expect(res.status).toBe(400);
});
it('returns 200 for an admin on Community (no paid gate)', async () => {
const { LicenseService } = await import('../services/LicenseService');
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
try {
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}`)
.send({ path: 'container-exec' });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
const decoded = jwt.verify(res.body.token, TEST_JWT_SECRET) as { scope?: string; path?: string };
expect(decoded.scope).toBe('console_session');
expect(decoded.path).toBe('container-exec');
} finally {
spy.mockReturnValue('paid');
}
});
it('returns 403 for non-admin user (viewer role)', async () => {
@@ -30,10 +30,9 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
// POST /api/system/console-token is paid-gated. Seed an active license so the
// parity assertion can observe the token the route returns. The
// license_last_validated fallback is skipped when the state key is absent, so
// the active status alone drives the paid tier.
// POST /api/system/console-token is admin-gated (available on every tier).
// Seed an active license only so other suites that share LicenseService
// state stay stable if they expect paid.
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().setSystemState('license_status', 'active');
});
@@ -43,12 +42,13 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
});
it('POST /api/system/console-token produces a token with the same shape as mintConsoleSession()', async () => {
const directToken = mintConsoleSession();
const directToken = mintConsoleSession({ path: 'host-console', actingAs: 'testadmin' });
const directDecoded = jwt.verify(directToken, TEST_JWT_SECRET) as Record<string, unknown>;
const res = await request(app)
.post('/api/system/console-token')
.set('Cookie', adminCookie);
.set('Cookie', adminCookie)
.send({ path: 'host-console' });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
@@ -57,6 +57,12 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
// Identical scope so the remote's upgrade handler treats both the same.
expect(routeDecoded.scope).toBe('console_session');
expect(directDecoded.scope).toBe('console_session');
expect(routeDecoded.path).toBe('host-console');
expect(directDecoded.path).toBe('host-console');
expect(typeof routeDecoded.jti).toBe('string');
expect(typeof directDecoded.jti).toBe('string');
expect(routeDecoded.acting_as).toBe('testadmin');
expect(directDecoded.acting_as).toBe('testadmin');
// Same claim keys: if somebody adds or drops a claim on one path but not
// the other, remote upgrade behavior will diverge.
@@ -58,6 +58,13 @@ describe('remoteWsForwardAllowed', () => {
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false);
});
it('denies every API-token scope for Host Console while preserving full-admin for container exec', () => {
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(false);
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false);
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'read-only', decoded: { scope: 'api_token' } })).toBe(false);
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(true);
});
it('allows an interactive path for a pre-gated console_session token', () => {
expect(remoteWsForwardAllowed(CONSOLE, { ...base, decoded: { scope: 'console_session' } })).toBe(true);
});
+95 -1
View File
@@ -8,7 +8,7 @@
* product paths. This test pins each position by observing behavior that
* could only originate from the expected handler.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import WebSocket from 'ws';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
@@ -420,6 +420,100 @@ describe('WebSocket upgrade dispatch order', () => {
expect(outcome.kind).toBe('unexpected');
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
});
it('rejects a full-admin API token remote Host Console with 403 (container exec stays allowed)', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id;
const rawToken = createTestApiToken({
db: DatabaseService,
scope: 'full-admin',
userId: adminId,
name: `host-console-api-deny-${Date.now()}`,
});
const consoleWs = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { bearer: rawToken });
const consoleOutcome = await waitForOutcome(consoleWs);
expect(consoleOutcome.kind).toBe('unexpected');
if (consoleOutcome.kind === 'unexpected') expect(consoleOutcome.status).toBe(403);
const execWs = connect(`/ws?nodeId=${remoteNodeId}`, { bearer: rawToken });
const execOutcome = await waitForOutcome(execWs);
if (execOutcome.kind === 'unexpected') expect(execOutcome.status).not.toBe(403);
try { execWs.terminate(); } catch { /* ignore */ }
});
});
describe('remote Host Console Community capability probe', () => {
// Community hubs must probe host-console-community before forwarding Host
// Console; Admiral hubs skip the probe so legacy remotes still work.
// Fail closed when the probe errors or the capability is missing.
const meta = (capabilities: string[]): import('../services/CapabilityRegistry').RemoteMeta => ({
version: '0.95.0',
capabilities,
startedAt: 1,
updateError: null,
online: true,
imagePinKind: null,
updateBlocked: false,
imageChannel: null,
});
async function setLicense(status: string): Promise<void> {
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().setSystemState('license_status', status);
}
afterEach(async () => {
vi.restoreAllMocks();
await setLicense('active');
});
it('rejects Community hub remote Host Console when the remote lacks host-console-community', async () => {
await setLicense('community');
const { NodeRegistry } = await import('../services/NodeRegistry');
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta(['host-console']));
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
const outcome = await waitForOutcome(ws);
expect(outcome.kind).toBe('unexpected');
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
});
it('rejects Community hub remote Host Console when the capability probe fails', async () => {
await setLicense('community');
const { NodeRegistry } = await import('../services/NodeRegistry');
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockRejectedValue(new Error('offline'));
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
const outcome = await waitForOutcome(ws);
expect(outcome.kind).toBe('unexpected');
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
});
it('forwards Community hub remote Host Console when the remote advertises host-console-community', async () => {
await setLicense('community');
const { NodeRegistry } = await import('../services/NodeRegistry');
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(
meta(['host-console-community']),
);
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
const outcome = await waitForOutcome(ws);
if (outcome.kind === 'unexpected') expect(outcome.status).not.toBe(403);
try { ws.terminate(); } catch { /* ignore */ }
});
it('skips the capability probe on an Admiral hub even when the remote lacks host-console-community', async () => {
await setLicense('active');
const { NodeRegistry } = await import('../services/NodeRegistry');
const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta([]));
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
const outcome = await waitForOutcome(ws);
if (outcome.kind === 'unexpected') expect(outcome.status).not.toBe(403);
expect(spy).not.toHaveBeenCalled();
try { ws.terminate(); } catch { /* ignore */ }
});
});
it('dispatches /api/pilot/tunnel to the pilot handler (rejects non-pilot bearer before path-based dispatch)', async () => {
+64 -10
View File
@@ -1,17 +1,55 @@
import { randomUUID } from 'crypto';
import jwt from 'jsonwebtoken';
import { DatabaseService } from '../services/DatabaseService';
/**
* Console session token lifetime. Short on purpose: these tokens are only
* used to bridge an already-authenticated HTTP request into a WebSocket
* upgrade, and each one is consumed by a single `wss:ws(target)` call.
* Console session token lifetime. Short on purpose: these tokens bridge an
* already-authenticated hub request into a remote WebSocket upgrade. Each
* token is path-scoped and consumed on first interactive WebSocket upgrade
* acceptance (before PTY spawn).
*/
const CONSOLE_SESSION_TTL_SECONDS = 60;
const CONSOLE_SESSION_SCOPE = 'console_session';
/** Interactive surfaces that may mint or accept a console_session JWT. */
export type ConsoleSessionPath = 'host-console' | 'container-exec';
export interface MintConsoleSessionOptions {
path: ConsoleSessionPath;
/** Hub operator identity for remote audit (option B). Never used as the session principal. */
actingAs?: string;
}
export interface ConsoleSessionClaims {
scope: typeof CONSOLE_SESSION_SCOPE;
username?: string;
path: ConsoleSessionPath;
acting_as?: string;
}
const ACTING_AS_MAX = 64;
const ACTING_AS_RE = /^[A-Za-z0-9._@+-]+$/;
/** Sanitize an optional hub operator name for the acting_as claim. */
export function sanitizeActingAs(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed || trimmed.length > ACTING_AS_MAX) return undefined;
if (!ACTING_AS_RE.test(trimmed)) return undefined;
return trimmed;
}
export function isConsoleSessionPath(value: unknown): value is ConsoleSessionPath {
return value === 'host-console' || value === 'container-exec';
}
/**
* Map a WebSocket pathname to the console_session path claim it requires.
* Returns null for surfaces that must not accept console_session tokens.
*/
export function consoleSessionPathForPathname(pathname: string): ConsoleSessionPath | null {
if (pathname.startsWith('/api/system/host-console')) return 'host-console';
if (pathname === '/ws') return 'container-exec';
return null;
}
/**
@@ -19,13 +57,19 @@ export interface ConsoleSessionClaims {
* instance without leaking the long-lived node api_token onto a
* machine-to-machine WebSocket. Throws if the JWT secret is not configured.
*/
export function mintConsoleSession(username?: string): string {
export function mintConsoleSession(opts: MintConsoleSessionOptions): string {
if (!isConsoleSessionPath(opts.path)) {
throw new Error('Invalid console session path');
}
const jwtSecret = DatabaseService.getInstance().getGlobalSettings().auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret configured');
const payload: ConsoleSessionClaims = username
? { scope: CONSOLE_SESSION_SCOPE, username }
: { scope: CONSOLE_SESSION_SCOPE };
return jwt.sign(payload, jwtSecret, { expiresIn: CONSOLE_SESSION_TTL_SECONDS });
const payload: ConsoleSessionClaims = { scope: CONSOLE_SESSION_SCOPE, path: opts.path };
const actingAs = sanitizeActingAs(opts.actingAs);
if (actingAs) payload.acting_as = actingAs;
return jwt.sign(payload, jwtSecret, {
expiresIn: CONSOLE_SESSION_TTL_SECONDS,
jwtid: randomUUID(),
});
}
/** True when `decoded.scope` is the console_session scope. */
@@ -33,4 +77,14 @@ export function isConsoleSessionScope(scope: unknown): boolean {
return scope === CONSOLE_SESSION_SCOPE;
}
export { CONSOLE_SESSION_SCOPE };
/**
* Mark a console_session jti as used. Returns false when the jti is missing,
* already consumed, or cannot be recorded (replay / malformed token).
*/
export function consumeConsoleSessionJti(jti: unknown, expiresAtMs: number): boolean {
if (typeof jti !== 'string' || !jti) return false;
if (!Number.isFinite(expiresAtMs)) return false;
return DatabaseService.getInstance().consumeConsoleSessionJti(jti, expiresAtMs);
}
export { CONSOLE_SESSION_SCOPE, CONSOLE_SESSION_TTL_SECONDS };
+1 -1
View File
@@ -103,7 +103,7 @@ auditLogRouter.get('/export', async (req: Request, res: Response): Promise<void>
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.csv"`);
const headers = ['id', 'timestamp', 'username', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
const headers = ['id', 'timestamp', 'username', 'acting_as', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
const rows = result.entries.map(e =>
headers.map(h => escapeCsvField(e[h as keyof typeof e])).join(','),
);
+21 -4
View File
@@ -1,8 +1,12 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { requireAdmin } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { mintConsoleSession } from '../helpers/consoleSession';
import {
isConsoleSessionPath,
mintConsoleSession,
sanitizeActingAs,
} from '../helpers/consoleSession';
/**
* Mint a short-lived `console_session` JWT. Used by the gateway when it
@@ -11,15 +15,28 @@ import { mintConsoleSession } from '../helpers/consoleSession';
* remote's upgrade handler on interactive paths, so the gateway authenticates
* with the long-lived token, asks for this short-lived one, then forwards
* the WS upgrade using it.
*
* Body:
* - `path` (required): `host-console` | `container-exec`
* - `acting_as` (optional): hub operator for remote audit. On node_proxy mints
* the body value is used (sanitized). On browser admin mints the signed-in
* username is stamped so a Bearer console_session cannot erase attribution.
*/
export const consoleRouter = Router();
consoleRouter.post('/console-token', authMiddleware, (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, 'API tokens cannot generate console tokens.')) return;
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
try {
res.json({ token: mintConsoleSession() });
const path = req.body?.path;
if (!isConsoleSessionPath(path)) {
res.status(400).json({ error: 'Invalid or missing console path' });
return;
}
const actingAs = req.machineAuthScope === 'node_proxy'
? sanitizeActingAs(req.body?.acting_as)
: sanitizeActingAs(req.user?.username);
res.json({ token: mintConsoleSession({ path, actingAs }) });
} catch (error) {
console.error('Failed to issue console token:', error);
res.status(500).json({ error: 'Failed to issue console token' });
@@ -38,6 +38,7 @@ export const CAPABILITIES = [
'notification-suppression',
'notification-suppression-schedule',
'host-console',
'host-console-community',
'container-exec',
'audit-log',
'scheduled-ops',
@@ -71,6 +72,12 @@ export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
export type Capability = (typeof CAPABILITIES)[number];
/** Legacy Host Console advertisement (Admiral hubs still accept this on remotes). */
export const HOST_CONSOLE_CAPABILITY = 'host-console' as const satisfies Capability;
/** Host Console works without a paid license on this node. */
export const HOST_CONSOLE_COMMUNITY_CAPABILITY = 'host-console-community' as const satisfies Capability;
/** Remotes that evaluate weekly maintenance windows on mute/suppression replicas. */
export const NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY =
'notification-suppression-schedule' as const satisfies Capability;
@@ -165,6 +172,7 @@ export function getActiveCapabilities(): readonly string[] {
*/
const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [
'host-console',
'host-console-community',
];
/** Disable capabilities that require a central->pilot path that is not yet wired. */
+43 -2
View File
@@ -613,6 +613,8 @@ export interface AuditLogEntry {
node_id: number | null;
ip_address: string;
summary: string;
/** Hub operator for remote console_session bridges; null/absent for direct sessions. */
acting_as?: string | null;
}
export interface SecretRow {
@@ -1240,12 +1242,20 @@ export class DatabaseService {
status_code INTEGER NOT NULL DEFAULT 0,
node_id INTEGER,
ip_address TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT ''
summary TEXT NOT NULL DEFAULT '',
acting_as TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username);
CREATE TABLE IF NOT EXISTS console_session_jtis (
jti TEXT PRIMARY KEY,
used_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_console_session_jtis_expires ON console_session_jtis(expires_at);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
@@ -1788,6 +1798,21 @@ export class DatabaseService {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
};
// Remote Host Console bridges record the hub operator separately from
// the console_session principal (username stays console_session).
maybeAddCol('audit_log', 'acting_as', 'TEXT');
// Cached INSERT may predate the column; rebuild on next flush.
this.auditLogInsertStmt = null;
this.db.exec(`
CREATE TABLE IF NOT EXISTS console_session_jtis (
jti TEXT PRIMARY KEY,
used_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_console_session_jtis_expires ON console_session_jtis(expires_at);
`);
maybeAddCol('stack_update_recovery_generations', 'artifacts_retired', 'INTEGER NOT NULL DEFAULT 0');
// Distributed API model columns
@@ -4462,6 +4487,21 @@ export class DatabaseService {
this.db.prepare('DELETE FROM pilot_enrollments WHERE node_id = ?').run(nodeId);
}
/**
* One-time consume for a console_session JWT id. Inserts the jti on first
* use; returns false when the jti was already recorded (replay).
*/
public consumeConsoleSessionJti(jti: string, expiresAtMs: number): boolean {
const now = Date.now();
return this.db.transaction(() => {
this.db.prepare('DELETE FROM console_session_jtis WHERE expires_at < ?').run(now);
const result = this.db.prepare(
'INSERT OR IGNORE INTO console_session_jtis (jti, used_at, expires_at) VALUES (?, ?, ?)',
).run(jti, now, expiresAtMs);
return result.changes > 0;
})();
}
// --- Stack Update Status ---
/**
@@ -5165,7 +5205,7 @@ export class DatabaseService {
this.auditLogBuffer = [];
if (!this.auditLogInsertStmt) {
this.auditLogInsertStmt = this.db.prepare(
'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary, acting_as) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
}
const stmt = this.auditLogInsertStmt;
@@ -5180,6 +5220,7 @@ export class DatabaseService {
entry.node_id,
entry.ip_address,
entry.summary,
entry.acting_as ?? null,
);
}
});
@@ -18,6 +18,8 @@ export interface ConsoleAuditContext {
// path always supplies a concrete id.
readonly nodeId: number | null;
readonly ipAddress: string;
/** Hub operator for remote console_session bridges; null for direct sessions. */
readonly actingAs?: string | null;
}
const CONSOLE_AUDIT_PATH = '/api/system/host-console';
@@ -97,6 +99,7 @@ export class HostTerminalService {
node_id: audit.nodeId,
ip_address: audit.ipAddress,
summary,
acting_as: audit.actingAs ?? null,
});
} catch (err) {
console.error('[HostConsole] Failed to write session audit log:', err);
+4 -3
View File
@@ -54,9 +54,10 @@ export function handleGenericWs(
if (isProxyToken) return reject(socket, 403, 'Forbidden');
// Admin enforcement: container exec requires admin role.
// console_session tokens are already admin-gated at creation time.
// API tokens reaching this point have full-admin scope (read-only /
// deploy-only are blocked by the upgrade handler's scope gate).
// console_session tokens are already admin-gated at creation time and
// path/jti-gated in upgradeHandler. API tokens reaching this point have
// full-admin scope (read-only / deploy-only are blocked by the upgrade
// handler's scope gate).
if (!decoded.scope) {
const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
if (!execUser) {
+33 -37
View File
@@ -2,22 +2,16 @@ import type { IncomingMessage } from 'http';
import type { Duplex } from 'stream';
import WebSocket, { WebSocketServer } from 'ws';
import { FileSystemService } from '../services/FileSystemService';
import { NodeRegistry } from '../services/NodeRegistry';
import { HostTerminalService } from '../services/HostTerminalService';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import {
isLicenseTier,
normalizeTier,
} from '../services/license-normalize';
import { LicenseService } from '../services/LicenseService';
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
import { ROLE_PERMISSIONS } from '../middleware/permissions';
import type { UserRole } from '../services/DatabaseService';
import { getErrorMessage } from '../utils/errors';
import { rejectUpgrade as reject } from './reject';
import { isConsoleSessionScope } from '../helpers/consoleSession';
interface HostConsoleContext {
nodeId: number;
decoded: { scope?: string; username?: string };
decoded: { scope?: string; username?: string; acting_as?: string };
isProxyToken: boolean;
wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
stackParam: string | null;
@@ -26,15 +20,16 @@ interface HostConsoleContext {
/**
* Handle `/api/system/host-console` WebSocket upgrades.
*
* Enforces three gates before spawning the host PTY:
* Enforces two gates before spawning the host PTY:
* 1. Machine-credential rejection: node_proxy tokens cannot reach an
* interactive host shell.
* interactive host shell directly (remote forwarding mints a
* console_session via POST /console-token instead).
* 2. RBAC: user session tokens require the `system:console` permission.
* console_session tokens are pre-gated at issuance (see
* `routes/console.ts`) and skip this check.
* 3. License: host console requires the paid tier. For console_session
* tokens the tier is trusted from the gateway-supplied header;
* otherwise the local LicenseService is consulted.
*
* Path scoping and one-time jti consumption are enforced in upgradeHandler
* before this handler runs.
*/
export function handleHostConsoleWs(
req: IncomingMessage,
@@ -46,11 +41,10 @@ export function handleHostConsoleWs(
if (isProxyToken) return reject(socket, 403, 'Forbidden');
const isConsoleSession = decoded.scope === 'console_session';
const isConsoleSession = isConsoleSessionScope(decoded.scope);
if (!isConsoleSession) {
const userRole = wsResolvedUser?.role;
const consolePermission: PermissionAction = 'system:console';
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes(consolePermission)) {
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes('system:console')) {
console.log('[HostConsole] Access denied: insufficient permissions', {
username: wsResolvedUser?.username || decoded.username,
role: userRole,
@@ -59,18 +53,18 @@ export function handleHostConsoleWs(
}
}
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
const ls = LicenseService.getInstance();
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
? normalizeTier(consoleTierHeader)
: ls.getTier();
if (consoleTier !== 'paid') {
return reject(socket, 403, 'Forbidden');
}
// Option B: console_session principal stays "console_session"; hub operator
// is recorded separately as acting_as. Browser sessions use the real user.
const consoleUsername = isConsoleSession
? 'console_session'
: (wsResolvedUser?.username || decoded.username || 'unknown');
const actingAs = isConsoleSession && typeof decoded.acting_as === 'string' && decoded.acting_as
? decoded.acting_as
: null;
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
console.log('[HostConsole] WebSocket upgrade accepted', {
username: consoleUsername,
actingAs,
nodeId,
stack: stackParam || '(root)',
});
@@ -86,10 +80,6 @@ export function handleHostConsoleWs(
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
hostConsoleWss.close();
let targetDirectory: string;
// The shell may end up rooted at a different node than requested if the
// requested node's directory cannot be resolved; the audit row must name
// the node the shell actually runs in, so track it alongside the directory.
let auditNodeId: number = nodeId;
try {
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
const resolved = HostTerminalService.resolveConsoleDirectory(baseDir, stackParam);
@@ -100,22 +90,28 @@ export function handleHostConsoleWs(
}
targetDirectory = resolved;
} catch (error) {
const fallbackNodeId = NodeRegistry.getInstance().getDefaultNodeId();
console.error('[HostConsole] Failed to resolve console directory; falling back to the default node base dir', {
console.error('[HostConsole] Failed to resolve console directory', {
user: consoleUsername,
actingAs,
nodeId,
fallbackNodeId,
stack: stackParam || '(root)',
error: getErrorMessage(error, 'unknown'),
});
targetDirectory = FileSystemService.getInstance(fallbackNodeId).getBaseDir();
auditNodeId = fallbackNodeId;
if (ws.readyState === WebSocket.OPEN) {
ws.send('Error: Failed to resolve console directory.\r\n');
ws.close();
}
return;
}
const auditCtx = { username: consoleUsername, nodeId: auditNodeId, ipAddress };
const auditCtx = { username: consoleUsername, actingAs, nodeId, ipAddress };
try {
HostTerminalService.spawnTerminal(ws, targetDirectory, auditCtx);
} catch (error) {
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: getErrorMessage(error, 'unknown') });
console.error('[HostConsole] Unhandled spawn error:', {
user: consoleUsername,
actingAs,
error: getErrorMessage(error, 'unknown'),
});
if (ws.readyState === WebSocket.OPEN) {
ws.send('Error: Failed to start terminal session.\r\n');
ws.close();
+20 -5
View File
@@ -5,6 +5,7 @@ import { LicenseService } from '../services/LicenseService';
import { wsProxyServer } from '../proxy/websocketProxy';
import { getErrorMessage } from '../utils/errors';
import { rejectUpgrade as reject } from './reject';
import { consoleSessionPathForPathname } from '../helpers/consoleSession';
/**
* Forward a WebSocket upgrade to a remote Sencho instance. Handles the
@@ -23,9 +24,14 @@ export async function handleRemoteForwarder(
req: IncomingMessage,
socket: Duplex,
head: Buffer,
opts: { pathname: string; target: { apiUrl: string; apiToken: string } },
opts: {
pathname: string;
target: { apiUrl: string; apiToken: string };
/** Hub browser operator; recorded as acting_as on the remote audit trail. */
actingAs?: string;
},
): Promise<void> {
const { pathname, target } = opts;
const { pathname, target, actingAs } = opts;
if (!target.apiUrl) return reject(socket, 503, 'Service Unavailable');
const wsTarget = target.apiUrl.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
@@ -38,24 +44,33 @@ export async function handleRemoteForwarder(
// direct api_token access. Pilot loopback targets skip this: there is no
// long-lived api_token to exchange, and host-console is disabled on pilot
// mode at the capability registry anyway.
const isInteractiveConsolePath = pathname === '/api/system/host-console' || pathname === '/ws';
const sessionPath = consoleSessionPathForPathname(pathname);
let bearerTokenForProxy = target.apiToken;
if (isInteractiveConsolePath && !isPilotLoopback) {
if (sessionPath && !isPilotLoopback) {
try {
const consoleHeaders = LicenseService.getInstance().getProxyHeaders();
const tokenRes = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/console-token`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${target.apiToken}`,
'Content-Type': 'application/json',
[PROXY_TIER_HEADER]: consoleHeaders.tier,
},
body: JSON.stringify({
path: sessionPath,
...(actingAs ? { acting_as: actingAs } : {}),
}),
});
if (!tokenRes.ok) {
console.error(`[WS Proxy] Remote console-token request failed: ${tokenRes.status}`);
return reject(socket, 502, 'Bad Gateway');
}
const data = await tokenRes.json() as { token?: string };
if (typeof data.token === 'string') bearerTokenForProxy = data.token;
if (typeof data.token !== 'string' || !data.token) {
console.error('[WS Proxy] Remote console-token response missing token');
return reject(socket, 502, 'Bad Gateway');
}
bearerTokenForProxy = data.token;
} catch (e) {
console.error('[WS Proxy] Failed to fetch remote console token:', getErrorMessage(e, 'unknown'));
return reject(socket, 502, 'Bad Gateway');
+90 -6
View File
@@ -20,6 +20,14 @@ import { validateApiToken, touchApiTokenLastUsed } from '../utils/apiTokenAuth';
import { isDebugEnabled } from '../utils/debug';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { isLicenseTier, normalizeTier } from '../services/license-normalize';
import { HOST_CONSOLE_COMMUNITY_CAPABILITY } from '../services/CapabilityRegistry';
import { remoteAdvertisesCapability } from '../helpers/remoteCapabilities';
import {
consoleSessionPathForPathname,
consumeConsoleSessionJti,
isConsoleSessionScope,
} from '../helpers/consoleSession';
function parseCookies(req: IncomingMessage): Record<string, string> {
const header = req.headers.cookie || '';
@@ -31,6 +39,17 @@ function parseCookies(req: IncomingMessage): Record<string, string> {
);
}
/**
* Parse ?nodeId= as a strict positive integer. Returns null when the param is
* absent (caller should use the default node). Returns undefined when the
* param is present but malformed (caller should 404).
*/
function parseStrictNodeIdParam(raw: string | null): number | null | undefined {
if (raw == null || raw === '') return null;
if (!/^[1-9][0-9]*$/.test(raw)) return undefined;
return Number(raw);
}
// The two WebSocket paths open to any authenticated user (read-only/deploy-only
// API tokens and non-admin sessions on a remote node). Defined once so the
// scope gate and the remote-forward gate cannot drift apart.
@@ -68,8 +87,15 @@ export function remoteWsForwardAllowed(
if (ctx.isProxyToken) return false;
// console_session is pre-gated as admin at issuance (routes/console.ts).
if (ctx.decoded.scope === 'console_session') return true;
// Reject opaque API tokens on remote Host Console forward (would mint
// console_session and get a host shell). Local denial is enforced in
// attachUpgrade before handleHostConsoleWs. Container exec (/ws) keeps
// its existing full-admin API-token allow.
if (pathname.startsWith('/api/system/host-console') && ctx.wsApiTokenScope) {
return false;
}
// A read-only/deploy-only api_token is already rejected for these paths by
// the scope gate; only full-admin survives to here.
// the scope gate; only full-admin survives to here (container exec /ws).
if (ctx.wsApiTokenScope) return ctx.wsApiTokenScope === 'full-admin';
const role = ctx.wsResolvedUser?.role;
if (!role) return false;
@@ -136,7 +162,16 @@ export function attachUpgrade(
try {
// Opaque sen_sk_ API tokens: handled before jwt.verify. Prefix +
// length + checksum reject malformed keys without touching SQLite.
let decoded: { username?: string; scope?: string; role?: string; tv?: number };
let decoded: {
username?: string;
scope?: string;
role?: string;
tv?: number;
path?: string;
acting_as?: string;
jti?: string;
exp?: number;
};
let wsApiTokenScope: string | null = null;
if (looksLikeApiToken(token)) {
const validation = validateApiToken(token);
@@ -151,7 +186,7 @@ export function attachUpgrade(
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number };
decoded = jwt.verify(token, jwtSecret) as typeof decoded;
}
// Node proxy tokens are machine-to-machine credentials and must never be
@@ -180,6 +215,21 @@ export function attachUpgrade(
const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
// Path-scoped, one-time console_session tokens for interactive surfaces.
// Consume runs at upgrade acceptance (before PTY spawn); missing exp/jti fail closed.
if (isConsoleSessionScope(decoded.scope)) {
const requiredPath = consoleSessionPathForPathname(pathname);
if (!requiredPath || decoded.path !== requiredPath) {
return reject(socket, 403, 'Forbidden');
}
if (typeof decoded.exp !== 'number' || typeof decoded.jti !== 'string' || !decoded.jti) {
return reject(socket, 401, 'Unauthorized');
}
if (!consumeConsoleSessionJti(decoded.jti, decoded.exp * 1000)) {
return reject(socket, 401, 'Unauthorized');
}
}
// Gate WebSocket paths by API token scope
if (wsApiTokenScope) {
if (wsApiTokenScope === 'read-only' || wsApiTokenScope === 'deploy-only') {
@@ -219,8 +269,11 @@ export function attachUpgrade(
return;
}
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
const parsedNodeId = parseStrictNodeIdParam(parsedUrl.searchParams.get('nodeId'));
if (parsedNodeId === undefined) {
return reject(socket, 404, 'Not Found');
}
const nodeId = parsedNodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
const node = NodeRegistry.getInstance().getNode(nodeId);
// Notification push channel: local only when no remote nodeId is
@@ -243,6 +296,23 @@ export function attachUpgrade(
if (!remoteWsForwardAllowed(pathname, { wsResolvedUser, wsApiTokenScope, isProxyToken, decoded })) {
return reject(socket, 403, 'Forbidden');
}
// Community hubs require host-console-community before forwarding Host
// Console (marks remotes that no longer paid-gate the console path).
// Admiral hubs skip the probe for legacy host-console peers. Fail
// closed on probe errors; never fall through to local handlers for a
// named remote.
if (
pathname.startsWith('/api/system/host-console')
&& LicenseService.getInstance().getTier() !== 'paid'
) {
const communityCapable = await remoteAdvertisesCapability(
nodeId,
HOST_CONSOLE_COMMUNITY_CAPABILITY,
);
if (!communityCapable) {
return reject(socket, 403, 'Forbidden');
}
}
// Resolve the proxy target through NodeRegistry so pilot-mode nodes
// (empty api_url + api_token, loopback bridge instead) and proxy-mode
// nodes share one dispatch path. Mirrors proxy/remoteNodeProxy.ts.
@@ -253,7 +323,11 @@ export function attachUpgrade(
// serve gateway-local data for a request that named a remote node.
return reject(socket, 503, 'Service Unavailable');
}
await handleRemoteForwarder(req, socket, head, { pathname, target });
await handleRemoteForwarder(req, socket, head, {
pathname,
target,
actingAs: wsResolvedUser?.username || decoded.username,
});
return;
}
@@ -264,6 +338,16 @@ export function attachUpgrade(
}
if (pathname.startsWith('/api/system/host-console')) {
// Opaque API tokens never open a local host shell (parity with the
// remote forward gate). Do not rely on missing wsResolvedUser alone.
if (wsApiTokenScope) {
return reject(socket, 403, 'Forbidden');
}
// Unknown / deleted nodeIds must not fall through to the hub compose
// root via getComposeDir's env default.
if (!node) {
return reject(socket, 404, 'Not Found');
}
handleHostConsoleWs(req, socket, head, {
nodeId,
decoded,