import type { ServerWebSocket } from 'bun'; import { mkdirSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH } from '@@/data-path'; import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config'; import { getUsers } from 'officerdb'; type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number }; type ShellInfo = { command: string; args: string[]; name: string }; type BridgeSession = { client: ServerWebSocket; sidecar: WebSocket | null; dockerId: string; port: number; pendingMessages: string[]; }; type ContainerInfo = { userId: number; email: string; dockerId: string; port: number; }; const HOST_SIDECAR_PORT = 5338; const sessions = new Map, BridgeSession>(); const containerMapPath = join(getHomeDir(''), '..', 'terminal-containers.json'); let dockerImageReady = false; let containersCache: Record | null = null; let hostSidecarProcess: ReturnType | null = null; const sendOutput = (ws: ServerWebSocket, data: string) => { try { ws.send(JSON.stringify({ type: 'output', data })); } catch { // ws already closed } }; const connectSidecar = async (port: number): Promise => { const delays = [200, 300, 500, 800, 1200, 1600, 2000]; let lastError: Error | null = null; for (const delay of delays) { try { const ws = await new Promise((resolve, reject) => { const socket = new WebSocket(`ws://127.0.0.1:${port}`); const timeout = setTimeout(() => { try { socket.close(); } catch { // ignore } reject(new Error('Terminal sidecar timeout')); }, 2000); socket.addEventListener('open', () => { clearTimeout(timeout); resolve(socket); }); socket.addEventListener('error', () => { clearTimeout(timeout); reject(new Error('Terminal sidecar connection failed')); }); }); return ws; } catch (err) { lastError = err instanceof Error ? err : new Error('Terminal sidecar connection failed'); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError ?? new Error('Terminal sidecar connection failed'); }; const ensureDockerImage = () => { if (dockerImageReady) return; const dockerPath = Bun.which('docker'); if (!dockerPath) throw new Error('Docker not found'); const tag = 'officer-terminal-sidecar:v1'; const inspect = Bun.spawnSync({ cmd: [dockerPath, 'image', 'inspect', tag], stdout: 'ignore', stderr: 'ignore' }); if (inspect.exitCode === 0) { dockerImageReady = true; return; } const dockerfilePath = fileURLToPath(new URL('./Dockerfile.terminal-sidecar', import.meta.url)); const build = Bun.spawnSync({ cmd: [dockerPath, 'build', '-f', dockerfilePath, '-t', tag, '.'], cwd: fileURLToPath(new URL('./', import.meta.url)), stdout: 'inherit', stderr: 'inherit', }); if (build.exitCode !== 0) throw new Error('Failed to build terminal sandbox image'); dockerImageReady = true; }; // Check whether a container already has the officer resource mounts. // We test for the global skills dir as a proxy for all mounts being present. const containerHasResourceMounts = (dockerId: string): boolean => { const dockerPath = Bun.which('docker') ?? 'docker'; const result = Bun.spawnSync({ cmd: [dockerPath, 'inspect', '--format', '{{range .Mounts}}{{.Source}}\n{{end}}', dockerId], stdout: 'pipe', stderr: 'ignore', }); if (result.exitCode !== 0) return false; return result.stdout.toString().includes(getGlobalSkillsDir()); }; const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string, email: string): { dockerId: string } => { ensureDockerImage(); const dockerPath = Bun.which('docker') ?? 'docker'; const dockerId = `officer-terminal-${userId}`; const tag = 'officer-terminal-sidecar:v1'; // Remove stale container with same name if it exists if (dockerContainerExists(dockerId)) { Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' }); } let uid = 1000; let gid = 1000; try { const stats = statSync(homeDir); uid = stats.uid; gid = stats.gid; } catch { // fallback to defaults } const containerHome = `/home/${username}`; const run = Bun.spawnSync({ cmd: [ dockerPath, 'run', '-d', '--name', dockerId, '--restart', 'unless-stopped', '-p', `127.0.0.1:${port}:${port}`, '-e', `TERMINAL_PTY_PORT=${port}`, '-e', 'TERMINAL_PTY_HOST=0.0.0.0', '-e', `TERMINAL_USER=${username}`, '-e', `TERMINAL_UID=${uid}`, '-e', `TERMINAL_GID=${gid}`, '-v', `${homeDir}:${containerHome}`, '-v', `${getGlobalSkillsDir()}:/officer/skills:ro`, '-v', `${getGlobalToolsDir()}:/officer/tools:ro`, '-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`, '-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`, '-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`, '-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`, '-w', containerHome, tag, ], stdout: 'inherit', stderr: 'inherit', }); if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container'); return { dockerId }; }; const stopDockerSidecar = (dockerId: string) => { const dockerPath = Bun.which('docker') ?? 'docker'; Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' }); }; const readDockerLogs = (dockerId: string) => { const dockerPath = Bun.which('docker') ?? 'docker'; const logs = Bun.spawnSync({ cmd: [dockerPath, 'logs', '--tail', '200', dockerId], stdout: 'pipe', stderr: 'pipe' }); if (logs.exitCode !== 0) return ''; return logs.stdout.toString().trim(); }; const loadContainerMap = async (): Promise> => { if (containersCache) return containersCache; const data = await Bun.file(containerMapPath) .json() .catch(() => ({})); containersCache = data as Record; return containersCache; }; const saveContainerMap = async (map: Record) => { containersCache = map; await Bun.write(containerMapPath, JSON.stringify(map, null, 2)); }; const getAvailablePort = (map: Record, userId: number) => { const base = 54000; const used = new Set(Object.values(map).map((item) => item.port)); let port = base + (userId % 1000); while (used.has(port)) port += 1; return port; }; const dockerContainerExists = (dockerId: string) => { const dockerPath = Bun.which('docker') ?? 'docker'; const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-a', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' }); return result.exitCode === 0 && result.stdout.toString().trim().length > 0; }; const dockerContainerRunning = (dockerId: string) => { const dockerPath = Bun.which('docker') ?? 'docker'; const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' }); return result.exitCode === 0 && result.stdout.toString().trim().length > 0; }; const dockerStart = (dockerId: string) => { const dockerPath = Bun.which('docker') ?? 'docker'; const result = Bun.spawnSync({ cmd: [dockerPath, 'start', dockerId], stdout: 'ignore', stderr: 'ignore' }); return result.exitCode === 0; }; export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => { // Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing) mkdirSync(getUserSkillsDir(email), { recursive: true }); mkdirSync(getUserToolsDir(email), { recursive: true }); const map = await loadContainerMap(); const existing = map[email]; if (existing && dockerContainerRunning(existing.dockerId)) { // Recreate if resource mounts are missing (e.g. first run after feature was added) if (!containerHasResourceMounts(existing.dockerId)) { console.log(`[terminal] recreating container for ${email} — resource mounts missing`); stopDockerSidecar(existing.dockerId); } else { return existing; } } if (existing && dockerContainerExists(existing.dockerId)) { if (!containerHasResourceMounts(existing.dockerId)) { stopDockerSidecar(existing.dockerId); } else if (dockerStart(existing.dockerId)) { return existing; } else { stopDockerSidecar(existing.dockerId); } } const port = existing?.port ?? getAvailablePort(map, userId); const docker = startDockerSidecar(port, homeDir, userId, username, email); const next = { userId, email, dockerId: docker.dockerId, port }; map[email] = next; await saveContainerMap(map); return next; }; const sidecarAlive = async (port: number): Promise => { try { const res = await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) }); return res.ok; } catch { return false; } }; const killSidecarOnPort = (port: number) => { try { const result = Bun.spawnSync({ cmd: ['fuser', '-k', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' }); if (result.exitCode === 0) console.log(`[terminal] killed stale sidecar on port ${port}`); } catch { // fuser not available or failed } }; const startHostSidecar = async () => { if (hostSidecarProcess) { hostSidecarProcess.kill(); await hostSidecarProcess.exited.catch(() => {}); hostSidecarProcess = null; } killSidecarOnPort(HOST_SIDECAR_PORT); await new Promise((resolve) => setTimeout(resolve, 200)); const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url)); hostSidecarProcess = Bun.spawn({ cmd: ['node', sidecarPath], env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) }, stdout: 'inherit', stderr: 'inherit', }); console.log(`[terminal] host sidecar started on port ${HOST_SIDECAR_PORT}`); }; export const initTerminalSidecars = async () => { await startHostSidecar(); ensureDockerImage(); const users = getUsers(); for (const user of users) { const homeDir = getHomeDir(user.email); mkdirSync(dirname(homeDir), { recursive: true }); mkdirSync(homeDir, { recursive: true }); try { await syncUserPiConfig(user.email).catch((err) => { console.error(`[terminal] failed to sync Pi config for ${user.email}:`, err); }); await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!); console.log(`[terminal] sidecar ready for ${user.email}`); } catch (err) { console.error(`[terminal] failed to start sidecar for ${user.email}:`, err); } } }; const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' }; const resolveCwd = (home: string, cwd?: string) => { if (!cwd || cwd === '~') return home; if (cwd.startsWith('~/')) return join(home, cwd.slice(2)); if (cwd.startsWith('/')) return join(home, cwd.slice(1)); return home; }; export const terminalWebsocket = { async open(ws: ServerWebSocket) { const { email, username, role, sandboxed } = ws.data; if (!sandboxed && role !== 'Super Admin') { sendOutput(ws, '\r\n[Permission denied] Host terminal requires Super Admin role.\r\n'); return; } if (!sandboxed) { const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: HOST_SIDECAR_PORT, pendingMessages: [] }; sessions.set(ws, session); let sidecar: WebSocket | null = null; try { sidecar = await connectSidecar(HOST_SIDECAR_PORT); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to connect host sidecar'; sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`); sessions.delete(ws); return; } session.sidecar = sidecar; sidecar.addEventListener('message', (ev) => { try { if (typeof ev.data === 'string') { ws.send(ev.data); } else { ws.send(new TextDecoder().decode(ev.data)); } } catch { // ws already closed } }); sidecar.send( JSON.stringify({ type: 'init', host: true, sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`, shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }, cwd: resolveCwd(process.env.HOME!, ws.data.cwd), homeDir: process.env.HOME, userLabel: email, cols: ws.data.cols, rows: ws.data.rows, }), ); for (const msg of session.pendingMessages) sidecar.send(msg); session.pendingMessages = []; return; } const cwd = getHomeDir(email); const userRoot = dirname(cwd); mkdirSync(userRoot, { recursive: true }); mkdirSync(cwd, { recursive: true }); const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: 0, pendingMessages: [] }; sessions.set(ws, session); let sidecar: WebSocket | null = null; let info: ContainerInfo | undefined; try { info = await ensureDockerContainer(email, ws.data.userId, cwd, username); sidecar = await connectSidecar(info.port); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar'; sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`); if (info) { const logs = readDockerLogs(info.dockerId); if (logs) { sendOutput(ws, `\r\n[Docker logs]\r\n${logs}\r\n`); } } sendOutput(ws, '\r\n[Process exited]\r\n'); if (info) stopDockerSidecar(info.dockerId); sessions.delete(ws); return; } session.sidecar = sidecar; session.dockerId = info.dockerId; session.port = info.port; sidecar.addEventListener('message', (ev) => { try { if (typeof ev.data === 'string') { ws.send(ev.data); } else { ws.send(new TextDecoder().decode(ev.data)); } } catch { // ws already closed } }); const containerHome = `/home/${username}`; sidecar.send( JSON.stringify({ type: 'init', sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`, shell: containerShell, cwd: resolveCwd(containerHome, ws.data.cwd), homeDir: containerHome, userLabel: email, cols: ws.data.cols, rows: ws.data.rows, }), ); for (const msg of session.pendingMessages) sidecar.send(msg); session.pendingMessages = []; }, message(ws: ServerWebSocket, raw: string | Buffer) { const session = sessions.get(ws); if (!session) return; const payload = typeof raw === 'string' ? raw : raw.toString(); if (!session.sidecar || session.sidecar.readyState !== WebSocket.OPEN) { session.pendingMessages.push(payload); return; } try { session.sidecar.send(payload); } catch { // ignore } }, close(ws: ServerWebSocket) { const session = sessions.get(ws); if (session?.sidecar) { try { session.sidecar.close(); } catch { // ignore } } sessions.delete(ws); }, drain() {}, };