import path from "path"; /** * Terminal image storage modes. * * - `local`: always write to the backend's mapped local storage. Deterministic: * never falls back to the remote SFTP path. * - `remote-sftp`: always write to the connected terminal's SSH host over SFTP. * Deterministic: never falls back to local storage. * - `auto`: pick by capability only — remote SFTP when a connected terminal * session exists, local storage otherwise. Configuration never influences * this choice. */ export const TERMINAL_IMAGE_STORAGE_MODES = [ "auto", "local", "remote-sftp", ] as const; export type TerminalImageStorageMode = (typeof TERMINAL_IMAGE_STORAGE_MODES)[number]; export interface TerminalImageStorageSettings { mode: TerminalImageStorageMode; /** Absolute path on the Termix backend where local-mode files are written. */ localDir: string; /** * Absolute path handed to the terminal agent in local mode. This is the * host-side view of `localDir` (e.g. /tmp mapped into the container); it is * the only path ever exposed to callers. */ hostPath: string; ttlMs: number; maxCount: number; maxBytes: number; /** Both localDir and hostPath were explicitly configured and must be probed. */ localMappingConfigured: boolean; } /** Settings-table keys. Persisted values always win over environment. */ export const TERMINAL_IMAGE_STORAGE_KEYS = { mode: "terminal_image_storage_mode", localDir: "terminal_image_local_dir", hostPath: "terminal_image_host_path", ttlMs: "terminal_image_ttl_ms", maxCount: "terminal_image_max_count", maxBytes: "terminal_image_max_storage_bytes", } as const; /** * Legacy environment variables from the original env-only configuration. They * seed defaults only when no database value exists for the same field. */ export const TERMINAL_IMAGE_STORAGE_ENV = { mode: "TERMIX_IMAGE_STORAGE_MODE", localDir: "TERMIX_IMAGE_DIR", hostPath: "TERMIX_IMAGE_HOST_PATH", ttlMs: "TERMIX_IMAGE_TTL_MS", maxCount: "TERMIX_MAX_IMAGE_COUNT", maxBytes: "TERMIX_MAX_IMAGE_STORAGE_BYTES", } as const; export const DEFAULT_IMAGE_TTL_MS = 3_600_000; export const DEFAULT_IMAGE_MAX_COUNT = 100; export const DEFAULT_IMAGE_MAX_BYTES = 5_368_709_120; export const DEFAULT_IMAGE_HOST_PATH = "/tmp/termix-image-v0"; export const MIN_IMAGE_MAX_BYTES = 1_048_576; export function defaultImageLocalDir(env: NodeJS.ProcessEnv): string { return path.resolve( path.join(env.DATA_DIR || "./db/data", "termix-image-v0"), ); } export function parseTerminalImageStorageMode( value: unknown, ): TerminalImageStorageMode | null { if (typeof value !== "string") return null; const normalized = value.trim().toLowerCase(); return (TERMINAL_IMAGE_STORAGE_MODES as readonly string[]).includes( normalized, ) ? (normalized as TerminalImageStorageMode) : null; } /** * Local write directory must be an absolute path without NUL bytes. Relative * values are rejected rather than resolved: a relative entry silently depends * on the process cwd, which differs between Docker, systemd and dev runs. */ export function parseImageLocalDir(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed || hasUnsafePathSyntax(trimmed)) return null; if (!path.isAbsolute(trimmed)) return null; return path.resolve(trimmed); } /** Agent-visible path. Always POSIX-style and absolute. */ export function parseImageHostPath(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed || hasUnsafePathSyntax(trimmed)) return null; if (!path.posix.isAbsolute(trimmed)) return null; return path.posix.normalize(trimmed); } /** * Numeric fields: unparseable values are rejected (caller falls through to the * next source); parseable but out-of-range values are clamped, matching the * original env-only behavior. */ function parseClampedInt(value: unknown, min: number): number | null { if (typeof value !== "string" && typeof value !== "number") return null; const parsed = typeof value === "number" ? value : Number.parseInt(value.trim(), 10); if (!Number.isFinite(parsed)) return null; return Math.max(min, Math.trunc(parsed)); } export interface SettingSource { get(key: string): Promise; /** Told about a stored or environment value that was ignored. */ warn?: (message: string) => void; } function hasUnsafePathSyntax(value: string): boolean { return ( /[\u0000-\u001f\u007f]/.test(value) || value.split(/[\\/]+/).some((segment) => segment === "..") || value.includes("//") || value.includes("\\") ); } /** * Resolves the effective image storage settings. * * Per-field precedence: a valid persisted database value wins; a legacy * TERMIX_IMAGE_* variable seeds the default only when no database value * exists; otherwise the built-in default applies. Invalid values are skipped * with a warning and resolution falls through to the next source. * * Mode compatibility: with no mode in the database or environment, an explicit * legacy local mapping (TERMIX_IMAGE_DIR) keeps old deployments on `local`; * everything else defaults to `auto`. */ export async function resolveTerminalImageStorageSettings( settings: SettingSource, env: NodeJS.ProcessEnv = process.env, ): Promise { const warnInvalid = (key: string, source: string) => settings.warn?.( `Ignoring invalid terminal image storage setting ${key} from ${source}`, ); async function pick( key: string, envName: string, parse: (value: unknown) => T | null, fallback: T, ): Promise { const stored = await settings.get(key); if (stored !== null) { const parsed = parse(stored); if (parsed !== null) return parsed; warnInvalid(key, "database"); } const fromEnv = env[envName]; if (fromEnv !== undefined) { const parsed = parse(fromEnv); if (parsed !== null) return parsed; warnInvalid(key, "environment"); } return fallback; } const dbModeRaw = await settings.get(TERMINAL_IMAGE_STORAGE_KEYS.mode); let mode: TerminalImageStorageMode | null = null; if (dbModeRaw !== null) { mode = parseTerminalImageStorageMode(dbModeRaw); if (mode === null) warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "database"); } if (mode === null) { const envModeRaw = env[TERMINAL_IMAGE_STORAGE_ENV.mode]; if (envModeRaw !== undefined) { mode = parseTerminalImageStorageMode(envModeRaw); if (mode === null) warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "environment"); } } if (mode === null) { // Legacy deployments configured local storage purely through // TERMIX_IMAGE_DIR; keep them on local mode unless a database value says // otherwise. mode = env[TERMINAL_IMAGE_STORAGE_ENV.localDir] !== undefined ? "local" : "auto"; } const legacyLocalDir = parseImageLocalDir( env[TERMINAL_IMAGE_STORAGE_ENV.localDir], ); const [localDir, hostPath, ttlMs, maxCount, maxBytes] = await Promise.all([ pick( TERMINAL_IMAGE_STORAGE_KEYS.localDir, TERMINAL_IMAGE_STORAGE_ENV.localDir, parseImageLocalDir, defaultImageLocalDir(env), ), pick( TERMINAL_IMAGE_STORAGE_KEYS.hostPath, TERMINAL_IMAGE_STORAGE_ENV.hostPath, parseImageHostPath, legacyLocalDir ?? DEFAULT_IMAGE_HOST_PATH, ), pick( TERMINAL_IMAGE_STORAGE_KEYS.ttlMs, TERMINAL_IMAGE_STORAGE_ENV.ttlMs, (value) => parseClampedInt(value, 0), DEFAULT_IMAGE_TTL_MS, ), pick( TERMINAL_IMAGE_STORAGE_KEYS.maxCount, TERMINAL_IMAGE_STORAGE_ENV.maxCount, (value) => parseClampedInt(value, 1), DEFAULT_IMAGE_MAX_COUNT, ), pick( TERMINAL_IMAGE_STORAGE_KEYS.maxBytes, TERMINAL_IMAGE_STORAGE_ENV.maxBytes, (value) => parseClampedInt(value, MIN_IMAGE_MAX_BYTES), DEFAULT_IMAGE_MAX_BYTES, ), ]); const persistedLocalDir = await settings.get( TERMINAL_IMAGE_STORAGE_KEYS.localDir, ); const persistedHostPath = await settings.get( TERMINAL_IMAGE_STORAGE_KEYS.hostPath, ); const localMappingConfigured = ((persistedLocalDir !== null && parseImageLocalDir(persistedLocalDir) !== null) || parseImageLocalDir(env[TERMINAL_IMAGE_STORAGE_ENV.localDir]) !== null) && ((persistedHostPath !== null && parseImageHostPath(persistedHostPath) !== null) || parseImageHostPath(env[TERMINAL_IMAGE_STORAGE_ENV.hostPath]) !== null || legacyLocalDir !== null); return { mode, localDir, hostPath, ttlMs, maxCount, maxBytes, localMappingConfigured, }; }