/** * Interactive container terminal over protomux-rpc. * TTY sessions stream raw PTY bytes (no demux). Supports multi-session via sessionId. * * Shell selection: try bash / sh / ash first with a short alive-probe so common * images enter the PTY quickly. Exotic shells and adaptive probe wrappers are * only tried if the fast path fails. Docker's exec create often succeeds even * when the binary is missing — failure shows up only after start (exit 126/127). */ import { PassThrough } from 'stream' import { docker } from '../services/docker.js' import { Pushes } from '../../shared/protocol.js' import logger from '../utils/logger.js' const SESSIONS_KEY = 'terminals' /** Alive-probe for bash/sh/ash fast path — fail missing binaries quickly. */ const SHELL_PROBE_FAST_MS = 90 const SHELL_PROBE_FAST_STEP_MS = 15 /** Alive-probe for fallback shells / probe wrappers. */ const SHELL_PROBE_MS = 350 const SHELL_PROBE_STEP_MS = 25 /** * Fast path: only bash, sh, ash (absolute + PATH). Tried first with a short * timeout so typical Debian/Ubuntu/Alpine containers attach almost immediately. */ export const FAST_SHELL_CANDIDATES = Object.freeze([ ['/bin/bash'], ['/usr/bin/bash'], ['bash'], ['/bin/sh'], ['/usr/bin/sh'], ['sh'], ['/bin/ash'], ['/usr/bin/ash'], ['ash'], ]) const FAST_SHELL_KEYS = new Set(FAST_SHELL_CANDIDATES.map((c) => c.join('\0'))) /** * Fallback shell candidates after the fast path. Prefer interactive shells, * then POSIX / busybox variants. First match that stays Running wins. * (bash/sh/ash also listed here for completeness; buildShellCandidates dedupes.) */ export const DEFAULT_SHELL_CANDIDATES = Object.freeze([ ['/bin/bash'], ['/usr/bin/bash'], ['bash'], ['/bin/sh'], ['/usr/bin/sh'], ['sh'], ['/bin/ash'], ['/usr/bin/ash'], ['ash'], ['/bin/zsh'], ['/usr/bin/zsh'], ['zsh'], ['/bin/dash'], ['/usr/bin/dash'], ['dash'], ['/bin/ksh'], ['/usr/bin/ksh'], ['ksh'], ['/bin/mksh'], ['/usr/bin/mksh'], ['mksh'], ['/bin/fish'], ['/usr/bin/fish'], ['fish'], ['/bin/busybox', 'sh'], ['/usr/bin/busybox', 'sh'], ['busybox', 'sh'], ['/busybox', 'sh'], ['/bin/busybox', 'ash'], ['/system/bin/sh'], // Android-ish images ]) /** * One-shot script: from a working sh, pick the best available interactive shell. * Used when /bin/sh (or equivalent) exists but we still want bash if present. * * Must be valid when passed to `sh -c`. Join with newlines (not spaces): * space-joining turns `done\\nfor` into `done for`, which dash/sh reject * as `Syntax error: "if" unexpected (expecting "done")`. */ const SHELL_PROBE_SCRIPT = [ 'for s in /bin/bash /usr/bin/bash bash /bin/zsh /usr/bin/zsh zsh /bin/ash /usr/bin/ash ash /bin/dash /usr/bin/dash dash /bin/ksh /usr/bin/ksh ksh /bin/mksh /usr/bin/mksh mksh /bin/fish /usr/bin/fish fish; do', ' if [ -x "$s" ]; then exec "$s"; fi', ' if command -v "$s" >/dev/null 2>&1; then exec "$s"; fi', 'done', 'for b in /bin/busybox /usr/bin/busybox busybox /busybox; do', ' if [ -x "$b" ]; then exec "$b" sh; fi', ' if command -v "$b" >/dev/null 2>&1; then exec "$b" sh; fi', 'done', 'if [ -x /bin/sh ]; then exec /bin/sh; fi', 'if [ -x /usr/bin/sh ]; then exec /usr/bin/sh; fi', 'exit 127', ].join('\n') /** Bootstrap wrappers that run SHELL_PROBE_SCRIPT. */ const PROBE_WRAPPERS = Object.freeze([ ['/bin/sh', '-c', SHELL_PROBE_SCRIPT], ['/usr/bin/sh', '-c', SHELL_PROBE_SCRIPT], ['sh', '-c', SHELL_PROBE_SCRIPT], ['/bin/bash', '-c', SHELL_PROBE_SCRIPT], ['bash', '-c', SHELL_PROBE_SCRIPT], ['/bin/ash', '-c', SHELL_PROBE_SCRIPT], ['ash', '-c', SHELL_PROBE_SCRIPT], ['/bin/busybox', 'sh', '-c', SHELL_PROBE_SCRIPT], ['busybox', 'sh', '-c', SHELL_PROBE_SCRIPT], ]) function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } function getSessions(session) { let map = session.state.get(SESSIONS_KEY) if (!map) { map = new Map() session.state.set(SESSIONS_KEY, map) } return map } function resolveSessionId(args) { return args.sessionId || args.terminalId || args.containerId || 'default' } /** * Human-readable shell label for logs / client banner. * Never include multi-line probe scripts (those dump into the PTY UI). * @param {string[]} cmd * @returns {string} */ export function describeShellCmd(cmd) { if (!Array.isArray(cmd) || cmd.length === 0) return 'shell' const cIdx = cmd.indexOf('-c') if (cIdx >= 0) { const script = String(cmd[cIdx + 1] || '') const isProbe = script.includes('for s in') || script.includes('\n') || script.length > 80 if (isProbe) { const launcher = cmd.slice(0, cIdx).join(' ') || cmd[0] return `${launcher} (auto)` } } // busybox sh, etc. if (cmd.length <= 3) return cmd.join(' ') return cmd[0] } /** * @param {Buffer|Uint8Array} chunk */ function toBase64(chunk) { return Buffer.isBuffer(chunk) ? chunk.toString('base64') : Buffer.from(chunk).toString('base64') } /** * @param {string[]} cmd * @returns {boolean} */ export function isFastShellCmd(cmd) { if (!Array.isArray(cmd) || !cmd.length) return false return FAST_SHELL_KEYS.has(cmd.join('\0')) } /** * Build ordered unique shell command lists for this startTerminal call. * Order: explicit override → bash/sh/ash (fast) → other shells → probe wrappers. * @param {object} args * @returns {string[][]} */ export function buildShellCandidates(args = {}) { /** @type {string[][]} */ const out = [] const seen = new Set() const push = (cmd) => { if (!Array.isArray(cmd) || !cmd.length) return const key = cmd.join('\0') if (seen.has(key)) return seen.add(key) out.push(cmd) } // Explicit user override first (still fall back if it fails) if (Array.isArray(args.cmd) && args.cmd.length) { push(args.cmd.map(String)) } else if (typeof args.cmd === 'string' && args.cmd.trim()) { push(args.cmd.trim().split(/\s+/)) } else if (args.shell) { push([String(args.shell)]) } // Fast path: bash → sh → ash only (short probe in openShellExec) for (const c of FAST_SHELL_CANDIDATES) push([...c]) // Fallbacks: zsh/fish/busybox/… for (const c of DEFAULT_SHELL_CANDIDATES) push([...c]) // Heavy adaptive sh -c probe last (only if nothing concrete attached) for (const c of PROBE_WRAPPERS) push(c) return out } /** * Safely tear down a failed probe stream / exec. * @param {import('stream').Duplex|null|undefined} stream */ function destroyStream(stream) { if (!stream) return try { stream.removeAllListeners?.() } catch { // ignore } try { stream.end?.() } catch { // ignore } try { stream.destroy?.() } catch { // ignore } } /** * After exec.start(), decide whether the process is a live shell. * Docker often creates exec configs for missing binaries; they exit 126/127 immediately. * * @param {object} exec dockerode Exec * @param {import('stream').Duplex} stream * @param {number} [timeoutMs] * @returns {Promise<{ ok: boolean, exitCode: number|null|undefined, buffered: Buffer[] }>} */ async function probeShellAlive( exec, stream, timeoutMs = SHELL_PROBE_MS, stepMs = SHELL_PROBE_STEP_MS ) { /** @type {Buffer[]} */ const buffered = [] let ended = false let streamError = null const onData = (chunk) => { buffered.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) } const onEnd = () => { ended = true } const onError = (err) => { streamError = err ended = true } stream.on('data', onData) stream.on('end', onEnd) stream.on('error', onError) stream.on('close', onEnd) const deadline = Date.now() + timeoutMs const pollStep = Math.max(10, Number(stepMs) || SHELL_PROBE_STEP_MS) /** @type {number|null|undefined} */ let exitCode let running = false while (Date.now() < deadline) { try { const info = await exec.inspect() running = Boolean(info.Running) exitCode = info.ExitCode if (running) { // Live process — interactive shell waiting for input detachProbeListeners(stream, onData, onEnd, onError) return { ok: true, exitCode, buffered } } // Not running: if it already exited, treat as failure (try next shell) if (info.Pid === 0 || exitCode != null || ended) { detachProbeListeners(stream, onData, onEnd, onError) return { ok: false, exitCode: exitCode ?? (streamError ? -1 : 127), buffered } } } catch { // inspect can race; keep polling } if (ended && !running) { detachProbeListeners(stream, onData, onEnd, onError) return { ok: false, exitCode: exitCode ?? 127, buffered } } await sleep(pollStep) } // Timeout: accept if still running or we got output (prompt) try { const info = await exec.inspect() running = Boolean(info.Running) exitCode = info.ExitCode } catch { // ignore } detachProbeListeners(stream, onData, onEnd, onError) if (running || buffered.length > 0) { return { ok: true, exitCode, buffered } } return { ok: false, exitCode: exitCode ?? 127, buffered } } function detachProbeListeners(stream, onData, onEnd, onError) { try { stream.off?.('data', onData) || stream.removeListener?.('data', onData) stream.off?.('end', onEnd) || stream.removeListener?.('end', onEnd) stream.off?.('close', onEnd) || stream.removeListener?.('close', onEnd) stream.off?.('error', onError) || stream.removeListener?.('error', onError) } catch { // ignore } } /** * Try shell candidates until one stays running. * @param {import('dockerode').Container} container * @param {string[][]} candidates * @param {boolean} useTty * @returns {Promise<{ exec: object, stream: import('stream').Duplex, cmd: string[], buffered: Buffer[] }>} */ export async function openShellExec(container, candidates, useTty) { /** @type {Error|null} */ let lastErr = null const tried = [] for (const Cmd of candidates) { const label = Cmd.join(' ') tried.push(label) let stream = null try { const exec = await container.exec({ Cmd, AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: useTty, Env: ['TERM=xterm-256color', 'COLORTERM=truecolor'], }) stream = await exec.start({ hijack: true, stdin: true, Tty: useTty, }) const fast = isFastShellCmd(Cmd) const probe = await probeShellAlive( exec, stream, fast ? SHELL_PROBE_FAST_MS : SHELL_PROBE_MS, fast ? SHELL_PROBE_FAST_STEP_MS : SHELL_PROBE_STEP_MS ) if (!probe.ok) { logger.debug('terminal shell candidate rejected', { cmd: label, exitCode: probe.exitCode, fast, }) destroyStream(stream) lastErr = new Error( `Shell not available: ${label}` + (probe.exitCode != null ? ` (exit ${probe.exitCode})` : '') ) continue } // Pause until live push handlers are attached so no bytes are dropped try { stream.pause?.() } catch { // ignore } logger.info('terminal shell selected', { cmd: label }) return { exec, stream, cmd: Cmd, buffered: probe.buffered } } catch (err) { lastErr = err instanceof Error ? err : new Error(String(err)) destroyStream(stream) logger.debug('terminal shell candidate error', { cmd: label, error: lastErr.message, }) } } const detail = tried.length ? ` Tried: ${tried.slice(0, 12).join(' · ')}${tried.length > 12 ? ' …' : ''}` : '' throw ( lastErr || new Error( `No working shell found in container.${detail} Install bash/sh or set startTerminal cmd.` ) ) } export function registerTerminalHandlers(session) { session.respond('startTerminal', async (args) => { const containerId = args.containerId if (!containerId) throw new Error('containerId required') const sessions = getSessions(session) const sessionId = resolveSessionId(args) // Replace any prior PTY for this exact session id (re-entry / restart). // Explicit unique sessionIds (details-*, popout-*) may coexist on the same // container so pop-out windows stay alive while the in-app tab reopens. if (sessions.has(sessionId)) { endOne(sessions, sessionId) } // Legacy clients key the session by containerId only — clear leftovers for // that container so re-open is clean. Explicit multi-session ids skip this. const explicitSession = args.sessionId != null && String(args.sessionId) !== '' && String(args.sessionId) !== String(containerId) && String(args.sessionId) !== 'default' if (!explicitSession) { cleanupTerminalsForContainer(session, containerId) } const useTty = args.tty !== false const shellCandidates = buildShellCandidates(args) const container = docker.getContainer(containerId) const { exec, stream, cmd, buffered } = await openShellExec( container, shellCandidates, useTty ) const entry = { containerId, exec, stream, sessionId, tty: useTty, cmd, } sessions.set(sessionId, entry) session.state.set('terminal', entry) const pushOut = (chunk, isErr = false) => { const channel = isErr ? Pushes.terminalErrorOutput : Pushes.terminalOutput const type = isErr ? 'terminalErrorOutput' : 'terminalOutput' try { session.push(channel, { type, containerId, sessionId, data: toBase64(chunk), encoding: 'base64', }) } catch (e) { logger.debug('terminal push failed', { error: e.message }) } } // Flush any prompt bytes captured during the probe window, then live stream for (const chunk of buffered) { pushOut(chunk, false) } if (useTty) { // Raw PTY — do NOT demux (would corrupt ANSI / binary) stream.on('data', (chunk) => pushOut(chunk, false)) } else { const stdout = new PassThrough() const stderr = new PassThrough() container.modem.demuxStream(stream, stdout, stderr) stdout.on('data', (chunk) => pushOut(chunk, false)) stderr.on('data', (chunk) => pushOut(chunk, true)) } try { stream.resume?.() } catch { // ignore } stream.on('end', () => { sessions.delete(sessionId) if (session.state.get('terminal') === entry) session.state.delete('terminal') }) stream.on('error', (err) => { logger.error('Terminal stream error', { containerId, error: err.message }) sessions.delete(sessionId) }) const cols = Number(args.cols) const rows = Number(args.rows) if (cols > 0 && rows > 0) { try { await exec.resize({ h: rows, w: cols }) } catch { // ignore } } const shellLabel = describeShellCmd(cmd) logger.info('Terminal session started', { containerId, sessionId, tty: useTty, shell: shellLabel, peer: session.id.slice(0, 12), }) return { success: true, message: `Terminal started for ${containerId}`, sessionId, containerId, tty: useTty, shell: shellLabel, cmd: Array.isArray(cmd) ? cmd.map((p) => (String(p).length > 80 ? `${String(p).slice(0, 40)}…` : p)) : cmd, } }) // Hot path: events (id=0) — keep handler sync/cheap; no audit/metrics in session layer session.respond( 'terminalInput', (args) => { const sessions = getSessions(session) const sessionId = resolveSessionId(args) const entry = sessions.get(sessionId) || session.state.get('terminal') if (!entry) return null if (args.containerId && args.containerId !== entry.containerId) return null let inputData if (args.encoding === 'base64') { inputData = Buffer.from(args.data || '', 'base64') } else { // Default utf8 string in JSON (optimal for keystrokes) inputData = Buffer.from(args.data || '', 'utf8') } if (inputData.length && entry.stream && !entry.stream.writableEnded) { entry.stream.write(inputData) } return null }, { hot: true } ) session.respond( 'terminalResize', async (args) => { const sessions = getSessions(session) const sessionId = resolveSessionId(args) const entry = sessions.get(sessionId) || session.state.get('terminal') if (!entry) return null if (args.containerId && args.containerId !== entry.containerId) return null const cols = Number(args.cols) const rows = Number(args.rows) if (cols > 1 && rows > 0) { try { await entry.exec.resize({ h: rows, w: cols }) } catch { // ignore transient resize errors } } return null }, { hot: true } ) session.respond('killTerminal', async (args) => { const sessions = getSessions(session) const sessionId = args.sessionId || args.terminalId // Specific session requested: only kill that one. Never fall through to // "current terminal" — a late kill after re-entry must not destroy the new PTY. if (sessionId) { if (sessions.has(sessionId)) { const entry = sessions.get(sessionId) endOne(sessions, sessionId) return { success: true, message: `Terminal session ${sessionId} killed`, sessionId, containerId: entry.containerId, } } return { success: false, message: 'No terminal session found' } } if (args.containerId) { const killed = cleanupTerminalsForContainer(session, args.containerId) if (killed) { return { success: true, message: `Killed ${killed} terminal(s) for ${args.containerId}` } } } const entry = session.state.get('terminal') if (entry) { endOne(sessions, entry.sessionId || 'default') session.state.delete('terminal') return { success: true, message: 'Terminal killed' } } return { success: false, message: 'No terminal session found' } }) /** Used by removeContainer / recreate before Docker delete. */ session._cleanupTerminalsForContainer = (containerId) => { cleanupTerminalsForContainer(session, containerId) } } /** * @param {import('../rpc/session.js').PeerSession} session * @param {string} containerId * @returns {number} sessions ended */ function cleanupTerminalsForContainer(session, containerId) { if (!containerId) return 0 const sessions = getSessions(session) const needle = String(containerId) let killed = 0 for (const [id, entry] of [...sessions.entries()]) { const cid = String(entry?.containerId || '') if ( cid === needle || (needle.length >= 12 && cid.startsWith(needle)) || (cid.length >= 12 && needle.startsWith(cid.slice(0, 12))) ) { endOne(sessions, id) killed += 1 } } const legacy = session.state.get('terminal') if (legacy) { const cid = String(legacy.containerId || '') if ( cid === needle || (needle.length >= 12 && cid.startsWith(needle)) || (cid.length >= 12 && needle.startsWith(cid.slice(0, 12))) ) { endOne(sessions, legacy.sessionId || 'default') session.state.delete('terminal') killed += 1 } } return killed } function endOne(sessions, sessionId) { const entry = sessions.get(sessionId) if (!entry) return try { entry.stream.end() } catch { // ignore } try { entry.stream.destroy?.() } catch { // ignore } sessions.delete(sessionId) } export function endTerminal(session) { const sessions = session.state.get(SESSIONS_KEY) if (sessions) { for (const id of [...sessions.keys()]) endOne(sessions, id) } session.state.delete('terminal') session.state.delete(SESSIONS_KEY) } export function cleanupTerminalOnClose(session) { endTerminal(session) }